\# 基于向量数据库的RAG应用开发实战:从原型到生产,我踩过的五个坑
一句话结论:RAG 不是把文档塞进向量库就完事了,分块策略、Embedding 模型选择、检索后处理和 Prompt 设计四个环节中任何一个出问题,结果都是垃圾进垃圾出。
事情的起因
去年团队做了一个内部知识库问答系统,把几百份技术文档扔进 Pinecone,接上 GPT-4,以为开箱即用。结果第一个 Demo 就给老板演示了"如何用公司文档回答出完全相反的结论"。
后来花了三周迭代了四个版本,才把准确率从 52% 干到 89%。这篇文章就是这三周经验的压缩包,你直接拿去用。
环境准备
技术栈选型:
Python 3.11 + LangChain 0.3 + ChromaDB + text-embedding-3-small + GPT-4o-mini
选 ChromaDB 而不是 Pinecone,原因很简单——本地开发调试快,不依赖网络。切到生产环境换 Pinecone/Qdrant,只改一行 connection 的事。
pip install langchain langchain-openai chromadb pypdf tiktoken
第一版:最朴素的实现(以及它为什么不行)
代码长这样
from langchain.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import Chroma
from langchain.chat_models import ChatOpenAI
from langchain.chains import RetrievalQA
# 1. 加载文档
loader = PyPDFLoader("company_handbook.pdf")
documents = loader.load()
# 2. 分块
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200
)
docs = text_splitter.split_documents(documents)
# 3. 建索引
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma.from_documents(
docs, embeddings, persist_directory="./chroma_db"
)
# 4. 问答
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
retriever=vectorstore.as_retriever(search_kwargs={"k": 4})
)
result = qa_chain.run("我们的年假政策是什么?")
这代码看着挺干净吧?网上 80% 的教程都长这样。但它有致命问题。
问题 1:分块把上下文切碎了
公司手册里年假政策横跨三页——第一页说"入职满一年享 5 天年假",第二页说"经理级额外加 3 天",第三页有个备注"当年离职按比例折算"。
chunk_size=1000 按字符硬切,这三条信息被分到三个不同的 chunk 里。检索时只捞到包含"5 天"的那一块,后面两条关键信息直接丢了。
教训:默认的分块参数几乎从不适合你的文档。先理解文档结构再决定怎么切。
问题 2:检索到的内容不够用
我把 k=4 改成 k=10,以为能缓解。结果召回更多 chunk 不假,但大部分是噪声——排在前面的 chunk 只包含"5 天",后面的跟年假完全无关。LLM 被噪声干扰,反而开始胡编。
第二版:四项改造
改造 1:语义分块,别按字符数切
对结构化文档,改用基于标题的分块策略:
from langchain.text_splitter import MarkdownHeaderTextSplitter
headers_to_split_on = [
("#", "标题1"),
("##", "标题2"),
("###", "标题3"),
]
markdown_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on
)
# 先转 Markdown 再按标题结构切
docs = markdown_splitter.split_text(markdown_content)
对于 PDF,我用 langchain 的 DocumentMerge 配合自定义逻辑——先提取章节号,按章节分块。
关键经验:chunk_size 取决于你文档的最小语义单元。如果一段技术文档介绍一个 API,完整包含"用途→签名→示例→返回值",那这整个就是一块。强行截断等于丢信息。
改造 2:多维检索增强
单一相似度检索不够用,我加了关键词覆盖:
from langchain.retrievers import EnsembleRetriever
from langchain.retrievers import BM25Retriever
# 向量检索
vector_retriever = vectorstore.as_retriever(
search_kwargs={"k": 6}
)
# 关键词检索(弥补向量检索对精确匹配的不足)
bm25_retriever = BM25Retriever.from_documents(docs)
bm25_retriever.k = 6
# 混合检索
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, vector_retriever],
weights=[0.3, 0.7]
)
为什么加 BM25? 向量检索擅长语义相似,但遇到"年假"和"年假政策"这种精确短语时,BM25 的精确匹配反而更可靠。两者加权后,准确率从 52% 升到 71%。
改造 3:RAPTOR——递归检索
碰到那种"相关政策散落在多处"的问题,我用了 RAPTOR 的思路——先检索到相关片段,再以这些片段为线索去捞它们的上下文段落:
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import LLMChainExtractor
compressor = LLMChainExtractor.from_llm(llm)
compression_retriever = ContextualCompressionRetriever(
base_compressor=compressor,
base_retriever=ensemble_retriever
)
这一步把"检索到的片段"扔给 LLM,让它判断哪些真正相关,剔除噪声。做完后准确率升到 82%。
改造 4:增强 Prompt,让 LLM 知道"不知道"
from langchain.prompts import PromptTemplate
prompt_template = """你是一个技术文档问答助手,请基于以下上下文回答问题。
上下文:
{context}
问题:{question}
规则:
1. 如果上下文中没有足够信息来回答问题,直接说"根据提供的文档无法回答这个问题"
2. 不要使用你在训练数据中学到的知识来补充
3. 如果上下文中的信息存在矛盾,指出矛盾之处
回答:"""
prompt = PromptTemplate(
template=prompt_template,
input_variables=["context", "question"]
)
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff",
retriever=compression_retriever,
chain_type_kwargs={"prompt": prompt}
)
经验:不加约束的 LLM 会自信地胡说。明确告诉它"可以不知道",答案质量反而明显提升。最终准确率 89%,剩下 11% 里有 8% 是 LLM 正确地说了"我不知道",这个结果我可以接受。
第三版(未必需要,但值得知道):元数据过滤
文档量大的时候,上面的方案还不够。加元数据过滤能大幅提升检索命中率:
# 存储时带上元数据
vectorstore = Chroma.from_documents(
docs, embeddings,
persist_directory="./chroma_db",
collection_metadata={"hnsw:space": "cosine"}
)
# 检索时过滤
retriever = vectorstore.as_retriever(
search_kwargs={
"k": 6,
"filter": {"章节": "假期政策"}
}
)
生产环境中,我会给每个文档打上:来源、章节、更新时间、文档版本。检索时先按元数据粗筛,再做语义搜索,速度和质量都有提升。
性能实测数据
在我自己的 MacBook Pro (M1 Pro) 上跑的基准数据:
| 版本 | 检索方式 | 准确率 | 平均响应时间 | 单次检索 token 消耗 |
|---|---|---|---|---|
| v1 | 基础向量检索 k=4 | 52% | 0.8s | ~1.2k |
| v1.1 | 基础向量检索 k=10 | 58% | 1.2s | ~2.8k |
| v2 | 混合检索 + 压缩 | 82% | 2.1s | ~4.1k |
| v2.1 | 混合检索 + 压缩 + 元数据过滤 | 87% | 1.6s | ~3.5k |
| v3 | 全部 + 优化 Prompt | 89% | 1.7s | ~3.6k |
从 v2 到 v2.1 token 消耗反而下降,原因是元数据过滤减少了送入压缩器的候选数。
还没解决的问题
- 文档更新后怎么增量更新向量库? 目前的做法是删掉整个 collection 重建。百来份文档还行,上了万份级别就需要真正的增量更新策略了。
- 多轮对话中的上下文污染——用户问"那经理级呢"的时候,如果上一轮没有缓存"年假"这个主题,检索结果可能直接跑偏。我试过在 query 中自动补充上文摘要,但还有不小优化空间。
- Embedding 模型的选择——
text-embedding-3-small够用,但某些垂直领域(比如法律、医疗)用领域微调的 embedding 效果会好得多。可惜目前中文领域微调 embedding 的选择太少。
RAG 门槛低但天花板高。从"能跑"到"好用",需要对每个环节有把控:分块粒度、检索策略、上下文管理、LLM 约束。任何一个环节都糊弄不过去。
如果你的 RAG 应用效果不好,别急着换模型,先检查检索链路——问题大概率出在那儿。

评论已关闭!