【大模型RAG生成式AI开发实战】《大模型RAG生成式AI开发实战》_81.[第8章 Chroma与开源模型] 开源RAG全栈实现:零成本搭建智能问答

不花一分钱,不用OpenAI API,只用开源模型+Chroma,手把手教你搭出一套能跑在生产环境的RAG智能问答系统!本文从零开始,把选型、切片、向量化、检索、生成、部署全链路讲透,看完这篇,你的笔记本就是一台AI服务器。
文字目录
- 1 零成本选型与基础环境
- 2 文档处理与切片策略
- 3 Chroma向量库实战
- 4 开源Embedding模型接入
- 5 开源LLM本地推理
- 6 RAG链路编排与优化
- 7 服务封装与全栈部署
- 8 写在最后
嗨,大家好呀,我是你的老朋友精通代码大仙。接下来我们一起学习 《大模型RAG生成式AI开发实战》81.[第8章 Chroma与开源模型] 开源RAG全栈实现:零成本搭建智能问答
看别人用RAG做智能问答,感觉就像搭积木,咔咔几下就成型;轮到自己上手,却发现手里只有一把散沙,连水都混不上。你是不是也看着满屏的OpenAI账单瑟瑟发抖?是不是下了几个模型却连启动都报错?是不是把文档一股脑塞进去,问出来的答案却驴唇不对马嘴?别慌,这种焦虑我太懂了。今天大仙就以学长的身份,带你从零开始,用纯开源方案搭一套能跑的全栈RAG。不用充钱,不用买卡,咱们的目标就是:让你的笔记本,原地变身AI问答服务器。
1 零成本选型与基础环境
很多兄弟一上来就问我:“大仙,跑大模型是不是得买4090?是不是得充OpenAI会员?” 我说打住!咱们这章的主题是“零成本”,核心就一个字:抠。但不是抠门,是精打细算。Embedding要轻,LLM要精,向量库要稳。今天这套组合拳,让你一台普通笔记本就能跑起来。
可现实呢?我见过太多新手,环境配了三天,热情全耗光。要么 pip install 了一套GPU版的PyTorch,结果电脑是AMD显卡或者集显,一运行就爆CUDA错。还有更离谱的,直接下载原版FP16的Qwen-14B,几十G的权重往内存里塞,电脑直接蓝屏给你看。有的兄弟觉得“模型越大越牛”,选型直接奔70B去。你想想,70B模型即使INT4量化也要吃掉巨量资源,你这是拿自行车拉火车皮啊。
来看看这些典型翻车现场:
# 坑1:不指定环境,依赖大爆炸
pip install torch transformers chromadb langchain
# 坑2:上来就搞大家伙,笔记本当场去世
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen-72B")
# 电脑:我谢谢你啊,直接内存溢出
听大仙一句劝,咱们分层选型,量力而行。
第一层,Embedding负责把文字变成向量。这个不需要大模型,一个几百MB的 bge-small-zh-v1.5 足够把中文语义抽得明明白白。CPU上跑,毫秒级响应。
第二层,向量数据库。Chroma是本机首选,无需额外装Postgres或者Milvus,一个 pip install chromadb 搞定持久化。
第三层,大语言模型。核心原则是:7B智商够用,GGUF量化能跑。llama.cpp 或者 Ollama 把你的CPU潜力榨干,8G内存照样流畅对话。
正确示范如下,你跟着敲,保准不踩雷:
# Step 1:创建隔离环境(别偷懒,这一步能省你三天 debug 时间)
conda create -n open-rag python=3.10
conda activate open-rag
# Step 2:安装CPU友好依赖
pip install chromadb sentence-transformers llama-cpp-python fastapi uvicorn
# Step 3:Embedding选型——小而美
from sentence_transformers import SentenceTransformer
embedder = SentenceTransformer('BAAI/bge-small-zh-v1.5')
print("Embedding模型加载完成,参数量小,语义够准")
# Step 4:LLM选型——Ollama一键搞定
# 终端执行:ollama pull qwen2.5:7b
# 这一步不需要写代码,模型已经躺在你的硬盘里了
这样做的好处是什么?你不用花一分钱,数据不需要上云,甚至连网都可以断着跑。隐私、成本、可控性,全都要。选型不贪大,能跑通是第一步。先让代码转起来,再考虑优化。
2 文档处理与切片策略
文档处理是RAG的隐形天花板。再好的检索,也救不了垃圾切片。很多新手直接把整篇PDF塞进去,或者按固定1000字符一刀切,导致语义断裂。你问“公司年假几天”,检索出来的却是行政制度的最后半截,连主语都丢了,这谁受得了?
更隐蔽的坑在于文档格式的多样性。一个PDF里既有表格又有代码块还有正文,你用默认的 CharacterTextSplitter 一刀切,表格被切得稀烂,代码块丢了换行,全都糊成一团。到时候向量检索匹配的是一堆无意义的碎片,生成质量能高才怪。
看看这串典型的错误操作:
from langchain.document_loaders import PyPDFLoader
from langchain.text_splitter import CharacterTextSplitter
loader = PyPDFLoader("employee_handbook.pdf")
docs = loader.load()
# 灾难现场1:直接整本塞,Embedding模型直接懵
collection.add(documents=[d.page_content for d in docs], ids=["1"])
# 灾难现场2:按字符硬切,完全不管语义边界
text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=0)
chunks = text_splitter.split_documents(docs)
切片不是切蛋糕,是切DNA——切错了,信息就断了。正确的姿势是:按文档类型选Loader,按语义边界切分,保留来源Metadata。
中文场景下,我强烈推荐 RecursiveCharacterTextSplitter。它会优先按段落切,段落太长再按句子,句子太长再按字,层层递进,保证语义完整。Chunk大小建议控制在300到500个中文字符,重叠部分(overlap)设50到100字,这样上下文不会断裂。表格和代码块尽量整块保留,不要拆开。
来看看正确示范:
from langchain.text_splitter import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 中文场景500字左右刚刚好
chunk_overlap=100, # 重叠100字,保证语义连贯
separators=["\n\n", "\n", "。", "!", "?", " ", ""], # 按优先级逐级拆分
length_function=len
)
chunks = text_splitter.split_documents(docs)
# 保留来源信息,后续溯源用得上
for chunk in chunks:
chunk.metadata["doc_id"] = "employee_handbook_2024"
这样做,检索时命中的才是完整的语义单元。模型看到的不再是碎片,而是有血有肉的段落,回答质量自然水涨船高。
3 Chroma向量库实战
Chroma是轻量级向量库里的瑞士军刀,但很多人把它用成了高级SQLite。新手最容易犯的错误,就是分不清内存模式和持久化模式。每次重启程序都重新加数据,因为用了 chromadb.Client(),数据全在内存里,进程一杀就灰飞烟灭。还有的兄弟所有文档放一个collection,不做分类,更新时只能全量删除,效率低到令人发指。
更有甚者,检索时只会裸查 query_texts,不会用 where 条件过滤metadata,也不会调整 n_results,默认只拿10条,结果把真正有用的信息漏在了后面。
看看这些让人血压升高的代码:
import chromadb
# 坑:内存模式,重启数据就没了!
client = chromadb.Client()
collection = client.create_collection("rag")
collection.add(documents=contents, ids=ids)
# 下次运行,数据没了,又得重新Embedding,慢得要死
# 而且检索时不过滤,不控制数量
results = collection.query(query_texts=["年假几天"], n_results=10)
# 如果有1000条记录,这10条未必是最好的
Chroma虽小,五脏俱全。把它当正经数据库用,别当临时缓存。正确做法是:一定要用 PersistentClient 指定存储路径;按业务拆分Collection,或者在metadata里打标签;检索时带上 where 过滤,并且把 n_results 先放大到20左右,为后续重排序留足空间。
正确的打开方式长这样:
# 持久化到本地目录,重启后数据依然在
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection(name="company_docs")
# 加数据时带上metadata,方便后续分类检索
for i, chunk in enumerate(chunks):
collection.add(
ids=[f"chunk_{i}"],
documents=[chunk.page_content],
metadatas=[{
"source": chunk.metadata.get("source", "unknown"),
"page": chunk.metadata.get("page", 0),
"category": "hr_policy"
}]
)
# 检索时过滤+控制数量,精准打击
results = collection.query(
query_texts=["年假有几天"],
n_results=20,
where={"category": "hr_policy"}
)
持久化、可过滤、可溯源,这三板斧下来,你的向量库才真正具备了工程化的底子。
4 开源Embedding模型接入
Embedding是RAG的翻译官,把人类语言翻译成向量。选错了翻译官,检索就成了聋子的耳朵——摆设。很多新手还在迷信OpenAI的ada-002,或者随便找个 all-MiniLM-L6-v2 就往中文文档上招呼。结果呢?问“年假政策”,检索出来的是“年假计划”,语义漂移得找不着北。
还有一类兄弟,每次检索都重新做Embedding,不把向量存起来,既浪费算力又拖慢速度。更隐蔽的坑是不做归一化,导致相似度计算失真,检索排序一塌糊涂。
# 坑1:用英文模型硬刚中文,效果稀碎
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('all-MiniLM-L6-v2') # 中文语义:我尽力了
# 坑2:每次都重新编码,不缓存向量
embedding = model.encode("查询语句")
# 然后临时算相似度,毫无工程意识
中文场景就得用中文特化的开源模型。BGE系列(BAAI)是目前开源界的扛把子,bge-small-zh-v1.5 体积小、速度快,bge-large-zh-v1.5 精度更高。本地推理,一次编码,永久存储到Chroma,查询时直接向量比对。
关键细节在于:建议手动计算Embedding后存入Chroma,而不是让Chroma自动转。这样可以复用模型实例,也能确保归一化参数一致。
from sentence_transformers import SentenceTransformer
# 选型:中文特化,轻量高效
model = SentenceTransformer('BAAI/bge-small-zh-v1.5')
# 编码文档,normalize_embeddings=True 保证向量在同一尺度
doc_embeddings = model.encode(
[c.page_content for c in chunks],
normalize_embeddings=True
).tolist()
# 直接给Chroma喂向量,省去重复计算
collection.add(
ids=ids,
documents=contents,
embeddings=doc_embeddings,
metadatas=metadatas
)
# 查询时同样归一化
query_embedding = model.encode(["年假有几天"], normalize_embeddings=True).tolist()
results = collection.query(
query_embeddings=query_embedding,
n_results=20,
where={"category": "hr_policy"}
)
Embedding是RAG的地基,地基不牢,地动山摇。用对开源模型,你的检索就已经赢了一半。
5 开源LLM本地推理
大模型是生成答案的笔,没有笔,检索到的素材只是废纸。但新手往往被硬件门槛吓退,以为本地跑LLM必须4090。或者用了HuggingFace的transformers直接加载FP16模型,16G内存直接爆炸,风扇起飞,最终OOM。
还有一种痛苦,叫“模型加载了,但答案只生成一半”。因为你没设置上下文长度 n_ctx,模型写到一半就断片了。Prompt写得像白开水,模型看了素材也当没看见,照样放飞自我瞎编乱造。
来看看经典的翻车现场:
from transformers import AutoModelForCausalLM, AutoTokenizer
# 直接加载FP16,普通笔记本当场去世
model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen-14B",
torch_dtype="auto"
)
# 内存不足,系统疯狂swap,最终抛出OOM
别让硬件焦虑挡住你。量化后的7B模型,足以应对90%的问答场景。没有好显卡?用GGUF量化模型加 llama-cpp-python,或者直接上Ollama。7B INT4量化模型,8G内存就能跑,CPU推理速度也能接受,每秒十几二十个token,完全够用。
推荐两条路:
方案A:Ollama(新手首选,一键开箱)
# 终端里一行命令,模型自动下载,服务自动启动
ollama pull qwen2.5:7b
Python里直接调,API兼容OpenAI格式:
import ollama
response = ollama.chat(
model='qwen2.5:7b',
messages=[{
'role': 'system',
'content': '你是一个严谨的助手,只根据给定材料回答。'
}, {
'role': 'user',
'content': '根据材料,年假有几天?'
}]
)
print(response['message']['content'])
方案B:llama-cpp-python(更灵活,适合深度定制)
from llama_cpp import Llama
# n_ctx 一定要给够,否则长文档回答到一半就断
llm = Llama(
model_path="./models/qwen2.5-7b-q4_k_m.gguf",
n_ctx=4096,
n_threads=4, # CPU推理时多线程加速
verbose=False
)
# 停止符设好,避免模型胡言乱语
output = llm(
"问题:年假有几天?\n答案:",
max_tokens=512,
stop=["\n\n", "问题:"],
temperature=0.3 # 降低随机性,RAG需要稳定输出
)
print(output["choices"][0]["text"])
数据不出本机,隐私MAX,费用为0。笔杆子握在自己手里,写出的答案才踏实。
6 RAG链路编排与优化
检索和生成不是简单拼接,而是要让大模型“带着镣铐跳舞”——有素材,但不胡编。很多新手把检索出来的chunk直接拼成字符串塞给LLM,没有提示词工程,结果模型根本不看素材,自己脑补答案。或者检索结果太多,超出上下文长度,后面的信息全被截断,答非所问。
更专业的坑是不做重排序(Rerank)。向量检索回来的Top-K,相关性未必是递减的,里面混着噪声。如果你把10个chunk全塞给LLM,等于逼它在垃圾堆里找金子,质量能高才怪。
看看这串粗暴的代码:
context = "\n\n".join(results['documents'][0])
prompt = f"根据以下内容回答问题:\n{context}\n\n问题:{question}"
# 结果:模型回答的内容根本不在素材里,或者把多个chunk混成一团乱麻
RAG的精髓不是“搜到了”,而是“用上了”。正确的链路应该像流水线一样精密:
Prompt里必须加约束:“请严格根据以下参考材料回答,如果材料里没有,就说不知道”。同时控制上下文长度:检索Top 10,用轻量级Cross-Encoder重排,取Top 3到5给LLM。加入对话历史管理,超过窗口要总结。最后让模型输出答案时标注来源chunk,做到有据可查。
上代码,给你一个完整的 rag_query 函数:
import numpy as np
from sentence_transformers import CrossEncoder
# 初始化重排序模型(轻量,CPU可跑)
reranker = CrossEncoder('BAAI/bge-reranker-base')
def rag_query(question: str) -> dict:
# 1. 向量化查询
q_emb = embed_model.encode([question], normalize_embeddings=True).tolist()
# 2. 向量检索,多召回一些给重排序留空间
hits = collection.query(
query_embeddings=q_emb,
n_results=10,
where={"category": "hr_policy"}
)
# 3. CrossEncoder精排——向量检索是海选,重排序是决赛
pairs = [[question, doc] for doc in hits['documents'][0]]
scores = reranker.predict(pairs)
top_indices = np.argsort(scores)[::-1][:3]
selected_docs = [hits['documents'][0][i] for i in top_indices]
sources = [hits['metadatas'][0][i] for i in top_indices]
# 4. 构造带约束的Prompt
context = "\n---\n".join([
f"[{idx+1}] {doc}" for idx, doc in enumerate(selected_docs)
])
prompt = f"""你是一个严谨的助手。请仅根据以下参考材料回答问题。
如果参考材料中没有答案,请明确说“根据现有资料无法回答”,不要编造。
参考材料:
{context}
用户问题:{question}
请用中文回答,并在引用后标注来源编号如[1][2]。"""
# 5. 调用开源LLM
resp = ollama.chat(
model='qwen2.5:7b',
messages=[{'role': 'user', 'content': prompt}]
)
return {
"answer": resp['message']['content'],
"sources": sources
}
提示词是你的缰绳,重排序是你的筛子。双管齐下,幻觉才能被锁进笼子里。
7 服务封装与全栈部署
能跑通的脚本只是玩具,能对外服务的API才是产品。很多新手代码全写在一个Python文件里,if __name__ == "__main__" 一跑,前端不知道怎么调。更致命的是,每次问答请求都重新加载模型、重新连接Chroma,耗时几十秒,用户体验直接归零。
还有人一说到部署就想到Django,配置复杂得像在修航母。咱们这是轻量级RAG,要的就是快、稳、省。
看看这个反例:
# 灾难:每次请求都重新加载,用户等得花儿都谢了
@app.post("/chat")
def chat(q: str):
model = SentenceTransformer('BAAI/bge-small-zh-v1.5') # 重复加载!
client = chromadb.PersistentClient(path="./chroma_db") # 重复连接!
# ... RAG逻辑 ...
从脚本到服务,是工程师思维的质变。用FastAPI做轻量级API,启动时预加载所有资源,请求来时只做推理和检索。
正确的骨架长这样:
from fastapi import FastAPI
from pydantic import BaseModel
import chromadb
from sentence_transformers import SentenceTransformer
import ollama
app = FastAPI()
# 启动时预加载,避免每次请求都初始化
# 这叫“依赖注入”或者“全局单例”,省资源省时间
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_collection("company_docs")
embed_model = SentenceTransformer('BAAI/bge-small-zh-v1.5')
class Query(BaseModel):
question: str
session_id: str = "default" # 预留扩展:多轮对话
@app.post("/chat")
def chat(q: Query):
try:
# 复用预加载的资源,毫秒级响应
q_emb = embed_model.encode([q.question], normalize_embeddings=True).tolist()
hits = collection.query(query_embeddings=q_emb, n_results=5)
# 简化版RAG(生产环境建议加上重排序)
context = "\n".join(hits['documents'][0])
prompt = f"根据材料回答:\n{context}\n\n问题:{q.question}"
resp = ollama.chat(
model='qwen2.5:7b',
messages=[{'role': 'user', 'content': prompt}]
)
return {
"answer": resp['message']['content'],
"status": "ok"
}
except Exception as e:
return {"answer": "服务内部错误", "status": "error", "detail": str(e)}
# 启动命令:uvicorn main:app --host 0.0.0.0 --port 8000
前端随便用什么,React、Vue、甚至纯HTML配个Fetch,调你这个 POST /chat 就行。部署方面,本地开发用 uvicorn,生产环境用 gunicorn + uvicorn 做进程管理,或者打个Docker镜像,走到哪部署到哪。
脚本证明想法,服务承载流量。FastAPI是开源RAG最优雅的放大器。
写在最后
走到这里,咱们已经把开源RAG的全栈链路彻底撸了一遍。从选型时的克制,到切片时的细腻;从Chroma的持久化治理,到Embedding模型的精准翻译;从开源LLM的本地安家,到检索与生成的精密编排;最后用FastAPI给这一切穿上工程化的外衣。你会发现,零成本不代表低质量,开源不代表不靠谱。恰恰相反,当你亲手把这条链路跑通,你对RAG的理解已经超越了调接口的层面,真正摸到了底层数据的脉搏。
编程这条路,说难也难,说简单也简单。难的是每一步都有坑,简单的是每一个坑都有前辈替你踩过。大仙我也不是天生就会,也是从一个又一个报错里爬出来的。所以今天我把这些经验摊开了、揉碎了讲给你听,就是希望你少走弯路,把时间花在真正创造价值的地方。
RAG只是大模型应用的一个起点。掌握了这套全栈能力,你未来做Agent、做多模态、做企业知识库,都有了坚实的基本盘。别怕你的笔记本慢,别怕你的环境简陋,能跑通的代码才是好代码,能解决问题的系统才是好系统。
编程之路不易,但每一步成长都算数。保持好奇,持续折腾,你也能成为别人眼中的代码高手。咱们下回见!
关注私信备注:“资料代找获取”,全网计算机学习资料代找:例如:
《课程:2026 年多模态大模型实战训练营》
《课程:AI 大模型工程师系统课程 (22 章完整版 持续更新)》
《课程:AI 大模型系统实战课第四期 (2026 年开课 持续更新)》
《课程:2026 年 AGI 大模型系统课 23 期》
《课程:2026 年 AGI 大模型系统课 21 期》
《课程:AI 大模型实战课 8 期 (2026 年 2 月最新完结版)》
《课程:AI 大模型系统实战课三期》
《课程:AI 大模型系统课程 (2026 年 2 月开课 持续更新)》
《课程:AI 大模型全阶课程 (2025 年 12 月开课 2026 年 6 月结课)》
《课程:AI 大模型工程师全阶课程 (2025 年 10 月开课 2026 年 4 月结课)》
《课程:2026 年最新大模型 Agent 开发系统课 (持续更新)》
《课程:LLM 多模态视觉大模型系统课》
《课程:大模型 AI 应用开发企业级项目实战课 (2026 年 1 月开课)》
《课程:大模型智能体线上速成班 V2.0》
《课程:Java+AI 大模型智能应用开发全阶课》
《课程:Python+AI 大模型实战视频教程》
《书籍:软件工程 3.0: 大模型驱动的研发新范式.pdf》
《课程:人工智能大模型系统课 (2026 年 1 月底完结版)》
《课程:AI 大模型零基础到商业实战全栈课第五期》
《课程:Vue3.5+Electron + 大模型跨平台 AI 桌面聊天应用实战 (2025)》
《课程:AI 大模型实战训练营 从入门到实战轻松上手》
《课程:2026 年 AI 大模型 RAG 与 Agent 智能体项目实战开发课》
《课程:大模型训练营配套补充资料》
更多推荐




所有评论(0)