· 王思腾 · 大模型工程 · 9 分钟阅读
动手实战:从零搭一个 RAG 知识库问答系统(含代码与生产化清单)
原理篇讲了 RAG 是什么,这篇带你亲手搭一个能跑的。从文档切分、向量化入库,到检索、重排、拼上下文、生成答案,给出最小可运行代码;然后把 Demo 推向生产:混合检索、Rerank、查询改写、相似度兜底、引用溯源、评测集和增量更新,附一份逐项可落地的调优清单。

RAG 原理(见检索增强生成篇)讲的是“为什么”和“链路长什么样”,这篇我们动手把它搭出来。目标是:丢进去一批文档,就能对着它们提问、拿到带来源的答案。先用最小代码跑通,再一步步把它加固到能上生产。
示例用 Python + LangChain + Chroma(本地向量库,零依赖起步),模型用 OpenAI 兼容接口——换成国内模型(通义、DeepSeek、智谱等)只需改 embedding 和 chat 的接入。
一、整体链路
flowchart TB
subgraph Off["离线 · 建库"]
D0["原始文档"] --> D1["加载 + 清洗"] --> D2["切分 Chunking"] --> D3["Embedding 向量化"] --> DB[("向量库")]
end
subgraph On["在线 · 问答"]
Q["用户提问"] --> Q1["查询改写"] --> Q2["向量+关键词混合检索"] --> RR["Rerank 重排"]
RR --> CTX["拼接上下文"] --> LLM["LLM 生成"] --> A["带引用的答案"]
end
DB --> Q2离线把文档变成向量存起来,在线把问题向量化、检索、重排、塞给模型。我们先做最朴素的版本,再逐层加东西。
二、准备依赖
pip install langchain langchain-openai langchain-community chromadb设好 API Key(用国内模型就换成对应的 base_url 和 key):
export OPENAI_API_KEY=sk-xxx三、离线:文档切分与入库
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_community.document_loaders import TextLoader, DirectoryLoader
# 1. 加载文档(支持整个目录、多种格式)
docs = DirectoryLoader("./docs", glob="**/*.txt", loader_cls=TextLoader).load()
# 2. 切分:中文文档 chunk 300~600 字,重叠 10%~20%
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=80,
separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""],
)
chunks = splitter.split_documents(docs)
# 3. 向量化并入库
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory="./chroma_db",
collection_metadata={"hnsw:space": "cosine"}, # 余弦相似度
)
print(f"入库 {len(chunks)} 个片段")切分是 RAG 效果的第一关,几个要点:
- 中文用递归切分器并显式给出中文标点分隔符,默认按英文换行切会把中文段落切碎;
chunk_size中文取 300~600 字、chunk_overlap取块大小的 10%~20%,重叠是为了避免一句话被拦腰截断、上下文丢失;- 保留元数据:每个 chunk 带上来源文件、页码、标题,后面做引用溯源和权限过滤全靠它。
四、在线:检索 + 生成
from langchain_openai import ChatOpenAI
from langchain.chains import RetrievalQA
llm = ChatOpenAI(model="gpt-4o", temperature=0)
qa = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff",
retriever=vectorstore.as_retriever(search_kwargs={"k": 4}),
return_source_documents=True,
)
result = qa.invoke({"query": "产品支持哪些退款方式?"})
print(result["result"])
for doc in result["source_documents"]:
print("来源:", doc.metadata.get("source"))chain_type="stuff" 是把检索到的片段全塞进一次调用,片段少时最简单。temperature=0 让答案更确定。到这一步,一个能用的知识库问答就跑通了。
五、从 Demo 到生产:逐项加固
跑通只值 10 分,剩下 90 分在检索质量和工程化上。
1. 混合检索(Hybrid Search)
纯向量检索擅长语义,但对产品型号、错误码、人名这类精确专有名词反而容易漏。叠加一路 BM25 关键词检索,再融合结果:
flowchart LR
Q["查询"] --> V["向量检索<br/>语义相似 Top-20"]
Q --> K["BM25 关键词<br/>精确匹配 Top-20"]
V --> F["融合 RRF"] --> RR["Rerank"]
K --> F2. Rerank 重排
向量检索是“双塔”近似,快但粗;用交叉编码器(Cross-Encoder,如 bge-reranker)把召回的 Top-20 逐一和问题精细打分,取 Top-4 给 LLM。这一步通常是单点收益最大的优化。
3. 查询改写
多轮对话里“它支持退款吗”里的“它”没法直接检索。先让模型把问题改写成独立、检索友好的表述(“XX 产品是否支持退款”),再去检索。
4. 相似度兜底,拒绝硬答
设相似度阈值,低于阈值说明库里没有,直接回答“知识库中未找到相关信息”,别让模型编:
你只能根据下面【参考资料】回答。若资料中没有相关内容,
请直接回复“知识库中未找到相关信息”,不要根据常识编造。
【参考资料】{context}
【问题】{question}5. 引用溯源
答案里标注来自哪个文档/页码,既方便用户核对,也让错误可定位。
6. 增量更新
知识库会变。设计成按文档 ID 做 upsert:文档更新就删掉旧 chunk、重建该文档的片段,而不是全库重建;可以用文件 hash 判断是否需要重新 embedding。
六、评测:没有数字就没有优化
RAG 最容易犯的错是“凭感觉调”。务必建一个评测集:
- 准备 50~100 条真实问答对(问题 + 标准答案/应命中的文档);
- 拆两个指标分别看:召回率(该找到的片段有没有检索到)和答案正确率(生成有没有答对);
- 召回不行就调切分、检索、Rerank;召回对了但答错,才去调 Prompt 和模型。
flowchart LR
E["评测集问答对"] --> R["跑检索"] --> M1{"召回命中?"}
R --> G["跑生成"] --> M2{"答案正确?"}
M1 -- 否 --> T1["调切分/混合检索/Rerank"]
M2 -- 否 --> T2["调 Prompt/模型/上下文"]把“检索问题”和“生成问题”分开定位,能省掉大量无效调参。
七、常见坑
- 垃圾进垃圾出:PDF 表格、图片文字、页眉页脚抽取错乱,会把脏东西喂给模型。文档清洗和解析往往比换模型更重要;
- 切分太碎或太大:太碎语义不全,太大稀释重点、还费 token,按上面经验值起步再按评测调;
- 只建不评:没有评测集,所有“优化”都是心理安慰;
- 忽视更新:文档过期后模型还在一本正经地回答旧政策,必须有增量同步;
- 权限没隔离:多租户场景要在检索阶段按元数据过滤,不能把别人的文档检索出来。
小结
- 最小 RAG 四步:切分 → 向量化入库 → 检索 → 拼上下文生成,几十行代码能跑通;
- 切分是第一关:中文显式指定标点分隔,chunk 300~600 字、重叠 10%~20%,带好元数据;
- 生产化收益排序通常是:Rerank ≈ 混合检索 > 查询改写 > 兜底拒答 > 引用溯源;
- 一定要相似度兜底,允许模型说“不知道”,这是挡幻觉的关键;
- 建评测集,把召回问题和生成问题分开定位;
- 文档清洗、增量更新、权限过滤这些“笨功夫”,才是 RAG 真正的护城河。
RAG 的上限不在模型,而在数据处理、检索质量和评测体系——模型谁都能调,差距全在这些不起眼的地方。
本文由 AI 辅助创作,所有内容仅供交流与参考。


