基于向量数据库的RAG应用开发实战

2026-05-09 21:55 基于向量数据库的RAG应用开发实战已关闭评论

\# 基于向量数据库的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,我用 langchainDocumentMerge 配合自定义逻辑——先提取章节号,按章节分块。

关键经验: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 消耗反而下降,原因是元数据过滤减少了送入压缩器的候选数。

还没解决的问题

  1. 文档更新后怎么增量更新向量库? 目前的做法是删掉整个 collection 重建。百来份文档还行,上了万份级别就需要真正的增量更新策略了。
  2. 多轮对话中的上下文污染——用户问"那经理级呢"的时候,如果上一轮没有缓存"年假"这个主题,检索结果可能直接跑偏。我试过在 query 中自动补充上文摘要,但还有不小优化空间。
  3. Embedding 模型的选择——text-embedding-3-small 够用,但某些垂直领域(比如法律、医疗)用领域微调的 embedding 效果会好得多。可惜目前中文领域微调 embedding 的选择太少。

RAG 门槛低但天花板高。从"能跑"到"好用",需要对每个环节有把控:分块粒度、检索策略、上下文管理、LLM 约束。任何一个环节都糊弄不过去。

如果你的 RAG 应用效果不好,别急着换模型,先检查检索链路——问题大概率出在那儿。

你可能感兴趣的文章

来源:每日教程每日一例,深入学习实用技术教程,关注公众号TeachCourse
转载请注明出处: https://teachcourse.cn/4126.html ,谢谢支持!

资源分享

分类:Android 标签:
调用相机拍照后截取指定尺寸大小 调用相机拍照后截取指定尺寸大小
一种简单易懂的方式描述Android开发常见的排序算法:归并排序 一种简单易懂的方式描述Android
Python框架Flask开发用户登录、注册、校验功能,存储到MySQL数据库 Python框架Flask开发用户登录、
Android常见设计模式:什么是装饰者模式? Android常见设计模式:什么是装

评论已关闭!