做 RAG 项目最难受的是什么?不是模型不够聪明,不是知识库没搭起来,而是检索出来的东西根本不对。用户问的问题明明很简单,召回的内容却南辕北辙,最后大模型一本正经地编了个答案,你还要在脑子里替他解释——“它其实学到了你的语气,但没学到你的知识”。
很多从 0 到 1 学习 AI 应用开发的同学,第一次跑通 RAG demo 时都很兴奋:文档切了、向量存了、问答能走了。但一旦进入真实业务,立刻暴露两个问题:召回不准和答案不可信。真正拉开差距的,不是谁会用 LangChain,而是谁理解检索链路里的每一个环节,并且能在实际场景中把精准度一点点调上去。
这篇文章不打算停留在“RAG 是什么”的科普层面,而是把 RAG 原理讲透,同时用一套可以落地的 Python 代码,演示从基础检索到高级检索(查询改写、混合检索、重排序、父子文档召回)的完整升级过程。读完你会得到一个判断:RAG 系统的天花板,很大程度取决于检索质量,而检索质量,是工程细节堆出来的。
1. 这篇文章真正要解决的问题
先聊一个现实问题:为什么同样是 RAG,别人做出来的问答系统能直接上线,你做的却只能在演示时流畅,一塞真实数据就崩?
原因往往不在大模型,而在检索链路上。
RAG 的全称是 Retrieval-Augmented Generation,检索增强生成。它的思路很直白:回答之前,先从外部知识库里把相关资料检索出来,把资料拼进 Prompt,让大模型基于这些资料回答。这样既不用重新训练模型,又能让模型拥有“私有知识”。
但问题也出在“检索”两个字上。
最朴素的 RAG 流程是:把文档切成固定长度的 chunk → Embedding 成向量 → 存进向量数据库 → 用户提问时也做 Embedding → 用向量余弦相似度召回 Top-K → 拼进 Prompt。这套流程 demo 完全没问题,真实场景却会踩很多坑:
- 切块太机械:固定长度切出来的 chunk 常常把一个完整语义割裂,召回来的内容答非所问。
- 问题表达与文档表达不一致:用户问“2024 年第二季度利润怎么样”,文档里写的是“Q2 net income”,向量相似度可能完全对不上。
- Top-K 召回只看向量距离:没有考虑关键词命中、文档重要程度、上下文连续性,导致真正有用的内容被埋没。
- 召回的 chunk 太碎:每个 chunk 只有几百字,信息不完整,大模型拿到碎片也只能瞎猜。
这篇文章要解决的就是这四类问题。我们会从 RAG 的基础原理出发,搭一个最小可用系统,然后逐步加入高级检索技术,让系统从“能跑”变成“能用”。适合三类读者:
- 刚开始学 AI 应用开发,想系统理解 RAG 链路的人;
- 已经写过简单 RAG demo,但发现效果不稳定、想优化检索质量的人;
- 准备面试 AI 应用开发工程师岗位,需要把 RAG 高级检索讲清楚的人。
2. RAG 核心概念与工作原理
2.1 什么是 RAG
RAG 的全称是 Retrieval-Augmented Generation,也就是“检索增强生成”。它的核心思路是:在让大模型回答问题之前,先从外部知识库中检索出与问题相关的资料,把检索结果注入 Prompt,再由大模型生成最终答案。
这个设计背后的原因是:大模型在训练时只见过公开的、截止到某个时间点的语料,不可能知道你的内部文档、最新公告、私有数据。要让它“知道”这些信息,不能每次都在线微调,代价太大;更聪明的办法是——知识不装进模型参数,而是放在模型外面,需要时现场检索。
这就是 RAG 与传统微调(Fine-tuning)最本质的区别:
| 对比维度 | 传统微调 | RAG |
|---|---|---|
| 知识存放位置 | 模型参数内 | 外部知识库(向量库/文档库) |
| 更新成本 | 高,需要重新训练 | 低,替换或新增文档即可 |
| 幻觉风险 | 中高,模型可能死记硬背 | 相对低,答案需基于检索内容 |
| 适合场景 | 改变模型风格、格式、固定能力 | 注入新知识、实时信息、私有资料 |
| 工程复杂度 | 需要训练基础设施 | 需要检索系统和 Prompt 管理 |
从成本角度看,RAG 对大多数业务场景更友好。它真正降低的是“知识更新成本”和“私有化部署成本”。你不需要训练专属模型,只需要把文档接入知识库,就能让大模型拥有对应领域的问答能力。
2.2 RAG 的标准工作链路
RAG 的标准链路可以拆成三个阶段:Index(离线索引)、Retrieve(在线检索)、Generate(生成回答)。
Index 阶段:
- 文档加载(Loader):把 PDF、Word、Markdown、HTML、数据库记录等各种格式的文字提取出来。
- 文本切块(Splitting):把长文档切分成适合向量化的文本块。
- 向量化(Embedding):用 Embedding 模型把文本块转成向量。
- 存储(Storage):把向量写入向量数据库,同时保存原始文本和元信息。
Retrieve 阶段:
- 查询理解:对用户提问做可能的分词、改写或扩展。
- 召回:从向量库/倒排索引中找到最相关的候选文档。
- 重排:对召回的候选结果进一步精排,筛选出真正有用的内容。
Generate 阶段:
- 构造 Prompt:把系统指令、检索到的文档片段、用户问题进行组装。
- 生成答案:交给大模型生成最终回答。
- 附加引用:更完备的系统会输出内容来源、参考文献,方便用户核对。
三个环节里最容易拉开差距的是 Retrieve。因为 Generate 现在各家大模型能力大同小异,Index 的向量化质量也比较成熟,但 Retrieve 却涉及切块策略、查询改写、召回算法、重排策略等多个环节,任何一个细节不到位,都会直接导致答案质量大幅下降。
2.3 为什么“高级检索”值得单独拿出来学
搜索引擎领域有一句话:“搜索的成功不在于算法多酷,而在于用户能用最快的方式找到想要的页面。”RAG 也一样,用户不会关心你的向量维度是 1024 还是 1536,他只会关心一个问题:“我问的事,你答对了没有。”
基础检索的问题是:只依赖向量相似度,缺少关键词语义对齐、缺少文档全局上下文、缺少搜索行业多年积累下来的工程技巧。高级检索就是把传统搜索引擎和向量检索结合起来,再加上大模型的能力,让召回质量产生质变。
高级检索通常包括以下技术:
- 查询改写(Query Rewriting):用大模型把口语化、模糊的问题转换成更适合检索的查询表达。
- 混合检索(Hybrid Search):BM25 关键词检索和向量稠密检索同时进行,再融合排序。
- 重排序(Rerank):用专门的 Cross-Encoder 模型对召回结果做精排。
- 父子文档召回(Parent Document Retriever):用小 chunk 做检索、用大 chunk 做上下文补充。
这几个技术不是炫技,它们是已经过大量真实场景验证的实践方案。接下来我们用代码逐步实现。
3. 环境准备与前置条件
为了把原理落到代码上,我们选择一套比较通用的技术栈,版本以实际项目为准,本文重点演示通用思路:
- 操作系统:Windows / macOS / Linux 均可;
- Python 3.9 以上(推荐 3.10);
- 向量数据库:FAISS(轻量,适合学习和原型验证),生产环境可换 Milvus / Qdrant / Elasticsearch;
- 用户提问时使用兼容 OpenAI 接口的大模型 API;
- 检索框架:LangChain 作为编排工具,同时可以手工写检索代码,降低框架黑盒造成的问题。
需要安装的核心依赖如下:
pip install langchain langchain-community langchain-openai langchain-text-splitters pip install faiss-cpu chromadb pip install pysqlite3-binary pip install openai requests beautifulsoup4 pypdf安装完成后,建议先验证一下 FAISS 是否正常:
python -c "from langchain_community.vectorstores import FAISS; print('FAISS OK')"如果使用 Chroma 作为演示知识库,也建议先跑通一行代码确认环境没问题:
python -c "import chromadb; client = chromadb.Client(); print('Chroma OK')"这里真正容易踩坑的地方是:不同版本的 LangChain 对 FAISS、Chroma 等组件的 API 变化较大。如果你在安装后出现模块找不到的错误,不要怀疑自己,大概率是版本兼容问题。可以统一安装 LangChain 0.1 或 0.2 系列的版本,保持依赖版本一致。
4. 核心流程拆解:从文档加载到知识库构建
这一节我们先实现 RAG 的 Index 阶段,整个过程分成四步,每一步都很关键。
4.1 文档加载
文档加载是 RAG 的第一个入口。常见的文档类型有 PDF、Word、Markdown、HTML、纯文本等。LangChain 提供了一系列加载器,底层其实就是把各种格式解析成纯文本。
下面是一个加载本地 Markdown 文件和一个 PDF 文件的示例:
from langchain_community.document_loaders import TextLoader, PyPDFLoader # 加载 Markdown 文档 markdown_loader = TextLoader("docs/rag_guide.md", encoding="utf-8") md_docs = markdown_loader.load() # 加载 PDF 文档 pdf_loader = PyPDFLoader("docs/product_manual.pdf") pdf_docs = pdf_loader.load() print(f"Markdown 文档块数: {len(md_docs)}") print(f"PDF 文档块数: {len(pdf_docs)}")这里有一点要注意:TextLoader 默认按文件整体加载成一个 Document 对象,并不会自动切分。PDF 加载器则会把每一页提取成一个 Document。这些原始的 Document 对象通常还需要经过切块才能进入下一步。
如果有很多文档,可以用 DirectoryLoader 批量加载:
from langchain_community.document_loaders import DirectoryLoader, TextLoader text_loader_kwargs = {"encoding": "utf-8"} loader = DirectoryLoader( "docs", glob="**/*.md", loader_cls=TextLoader, loader_kwargs=text_loader_kwargs, ) all_docs = loader.load()4.2 文本切块:为什么 chunk_size 和 overlap 这么重要
文本切块是 RAG 中一个看起来简单、实际上决定系统上限的环节。
如果 chunk 太大,向量化后的语义会被稀释,一个 chunk 里包含多个子主题,检索时相似度计算会偏向“笼统的相似”,而不是精准命中;如果 chunk 太小,切出来的文本块可能只有一两句话,信息不完整,即使命中了,大模型也缺乏足够的上下文做出靠谱回答。
所以实际工程中通常采用chunk_size + chunk_overlap的组合:每个 chunk 有一个目标长度,相邻 chunk 之间有重叠区域。重叠的意义在于:当一句话被切在两个 chunk 的交界处时,前后两个 chunk 都能保留这句话的完整语义,避免信息被“拦腰截断”。
LangChain 中常用RecursiveCharacterTextSplitter,它按一组分隔符递归拆分文本,尽可能保留语义完整:
from langchain_text_splitters import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=512, chunk_overlap=64, separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""], ) chunks = text_splitter.split_documents(all_docs) print(f"切块后文档块数: {len(chunks)}") print(f"第一个 chunk: {chunks[0].page_content[:200]}")关于 chunk 大小,没有统一标准,但有一个经验区间:中文业务文档常见选择是 256~512 个 token,英文技术文档可以适当放大到 512~1024。更精确的方法是根据 Embedding 模型的最大输入长度和文档结构来决定。如果文档本身有明确的小节(Markdown 标题、PDF 标题),按结构切块往往优于纯长度切块。
4.3 向量化与存储
切好 chunk 之后,需要把每个 chunk 转换成向量。Embedding 模型的选择非常关键。常见选项包括:
- OpenAI 的 text-embedding-3-small / text-embedding-3-large;
- 国内的 bge-large-zh、m3e-base 等中文友好模型;
- 开源本地模型如 BAAI/bge-m3。
如果你的业务是中文场景,优先选择对中文效果好的 Embedding 模型。如果使用的是兼容 OpenAI 接口的服务,代码上差别不会很大。
下面是用 OpenAI Embedding 接口配合 FAISS 构建向量索引的示例:
from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import FAISS # 使用兼容 OpenAI 接口的 Embedding 服务 embeddings = OpenAIEmbeddings( model="text-embedding-3-small", base_url="https://api.openai.com/v1", ) vectorstore = FAISS.from_documents(chunks, embeddings) # 保存到本地,便于重复使用 vectorstore.save_local("faiss_index")这段代码执行完后,本地会出现一个faiss_index文件夹,里面包含向量索引文件和文档元数据。
这里真正需要强调的点是:向量数据库的核心价值不是“存向量”,而是“快速找到与查询最相似的向量”。FAISS 在单机小规模场景下完全够用,但一旦文档量到百万级,或者需要实时更新、高并发访问,就必须切换到 Milvus、Qdrant、Elasticsearch 这类分布式向量数据库。
4.4 加载已有索引并执行基础检索
第二次使用时,不需要重新向量化所有文档,直接加载本地的 FAISS 索引即可:
vectorstore = FAISS.load_local( "faiss_index", embeddings, allow_dangerous_deserialization=True, )然后对一个查询做最基础的相似度检索:
query = "2024年第二季度营收是多少?" retrieved_docs = vectorstore.similarity_search_with_score(query, k=5) for doc, score in retrieved_docs: print(f"相似度: {score:.4f} | 内容: {doc.page_content[:100]}")similarity_search_with_score返回文档和相似度分数。FAISS 默认返回的距离分数越小代表越相似,这一点在和向量数据库对接时一定要注意,不同数据库的相似度定义千差万别,不要想当然。
到这一步,一个“最小可用 RAG”已经跑通了。你可以把 Top-K 结果和大模型 API 拼起来做问答。但正如开头所说,这个版本只能用于演示,离“高级检索”还有明显距离。
5. 高级检索实战:从基础问答到精准召回
基础检索的局限在于:它只用一次向量相似度计算就定了生死。用户提问方式一变、关键词没对上、切块位置不合理,召回结果就会失效。
高级检索的思路是:在向量检索基础上,叠加查询改写、混合检索、重排序、父子文档召回等手段,让最终进入大模型的上下文更精准。
5.1 查询改写:让问题更符合文档表达习惯
同一件事,用户的表达和文档的表达常常不一样。比如用户问“去年公司赚了多少钱”,文档里写的是“2023 年度净利润”。直接拿“去年公司赚了多少钱”做向量检索,不一定能命中“2023 年度净利润”那一段。
查询改写(Query Rewriting)的思路是:先用大模型把用户的原问题翻译成若干个适合检索的查询语句,再用这些查询去检索。常见做法包括:
- 对模糊问题补充背景信息;
- 把口语转化为书面语;
- 拆解复合问题为多个子问题;
- 生成同义表达,增加召回覆盖面。
下面是一个用大模型做查询改写的示例:
from langchain_openai import ChatOpenAI llm = ChatOpenAI( model="gpt-4o-mini", temperature=0, ) rewrite_prompt = """ 你是一个检索查询改写专家。请根据原始问题,生成 3 个适合在文档库中检索的查询表达。 要求: 1. 覆盖不同表达方式 2. 使用文档中可能的业务术语 3. 不要添加与原始问题无关的内容 原始问题:{query} 请直接输出改写后的查询,每行一个,不要编号。 """ def rewrite_queries(query: str) -> list[str]: response = llm.invoke(rewrite_prompt.format(query=query)) return [line.strip() for line in response.content.strip().splitlines() if line.strip()] query = "今年我们的利润情况怎么样?" queries = rewrite_queries(query) print(queries)输出可能是这样的:
今年我们的利润情况怎么样? 本年度利润数据是多少? 2024年公司净利润表现如何?然后用这些改写后的查询分别执行向量检索,把结果合并去重,再进入后续精排。这里的关键是:改写不是让大模型自由发挥,而是让查询更贴近文档的语言空间,提升命中的概率。
如果你不想引入大模型做改写,也可以用简单规则:比如从知识库标签中匹配关键词、将同义词替换为业务标准术语。不过在大模型成本足够低的今天,大模型改写往往是效果最好、实现成本最低的方案。
5.2 混合检索:向量 + BM25 关键词召回
向量检索擅长捕捉语义相似,但对精确词匹配不敏感。举个例子,文档里反复出现内部产品代号“A-100”,如果用户问的是“A-100 的维护周期”,向量检索可能被“维护周期”干扰,把其他设备的维护文档也召回来。此时精准的关键词匹配反而更有效。
混合检索(Hybrid Search)就是把向量检索和 BM25 关键词检索结合起来,两个通道分别召回,然后把结果融合。
BM25 是传统搜索引擎中最经典的相关性排序算法,基于词频和文档长度来打分。在 LangChain 中,可以直接用BM25Retriever:
from langchain_community.retrievers import BM25Retriever # 用之前的 chunks 构建 BM25 检索器 bm25_retriever = BM25Retriever.from_documents(chunks) bm25_retriever.k = 5 # 向量检索器 vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})然后分别检索,再合并结果:
query = "A-100 产品维护周期" bm25_docs = bm25_retriever.invoke(query) vector_docs = vector_retriever.invoke(query) print(f"BM25 召回数: {len(bm25_docs)}") print(f"向量召回数: {len(vector_docs)}")合并之后会面临一个问题:两个通道的分数尺度不同,怎么融合排序?简单方案是加权相加:给向量检索和 BM25 检索分别赋予权重,然后排序。更稳妥的方案是把合并后的候选集交给 Rerank 模型做精排。
需要注意的是,BM25 对中文需要分词支持,英文和中文的处理方式不同。如果你用的是英文文档,直接用 jieba 分词后切分即可;如果是中文文档,建议在构建 BM25 索引前先对文本做 jieba 分词,否则直接按空格分词会把整句汉语当成一个词,匹配效果非常差。
5.3 重排序:Cross-Encoder 精排召回结果
经过查询改写和混合检索,我们拿到的候选集可能包含 10~20 个文档块,但这些候选之间还有质量差异,Top 位置不一定是最相关的。这时候就需要重排序。
重排序通常使用 Cross-Encoder 模型。它的特点是:把查询和文档拼接成一个输入,一起送入模型,模型输出一个相关性强弱分数。相比 Bi-Encoder(查询和文档分别编码再算相似度),Cross-Encoder 的精度更高,但因为要做两两拼接,计算成本也更高。因此实际工程中通常先用向量检索粗召回,再用 Cross-Encoder 精排前几十个候选,保证准确率和开销达到平衡。
在代码中,可以使用FlagEmbedding或 HuggingFace 的 CrossEncoder:
# 安装依赖:pip install sentence-transformers from sentence_transformers import CrossEncoder # 选择中文/多语言重排序模型 reranker = CrossEncoder("BAAI/bge-reranker-v2-m3") def rerank_documents(query: str, docs: list, top_k: int = 3): pairs = [(query, doc.page_content) for doc in docs] scores = reranker.predict(pairs) scored_docs = sorted(zip(docs, scores), key=lambda x: x[1], reverse=True) return scored_docs[:top_k] combined_docs = deduplicate(bm25_docs + vector_docs) # 去重 top_docs = rerank_documents(query, combined_docs, top_k=3) for doc, score in top_docs: print(f"rerank 分数: {score:.4f} | 内容: {doc.page_content[:120]}")加入 Rerank 之后,效果往往有明显提升。因为向量检索的结果可能把“看起来像”的内容排前面,而 Rerank 模型更贴近真实的相关性判断。
这一节最关键的工程技巧是:粗召回阶段追求覆盖率,精排阶段追求准确率。不要在一开始就限制 Top-K 太小,否则 Rerank 模型手里没有“子弹”。推荐粗召回取 20~50 个候选,精排后输出 3~5 个。
5.4 父子文档召回:解决 chunk 太碎的问题
前面提到,chunk 太小会导致上下文不完整。但 chunk 太大又会影响检索精度。父子文档召回(Parent Document Retriever)是一种兼顾两者的方案:
- 把文档切分成较粗的“父文档”(比如一个章节,甚至整个文档);
- 再从父文档里切出较细的“子文档”(比如 512 token 的小 chunk);
- 检索时,只用子文档去计算相似度,确保召回精准;
- 拿到命中的子文档后,自动返回它所属的父文档,确保上下文完整。
LangChain 中可以直接使用ParentDocumentRetriever:
from langchain.retrievers import ParentDocumentRetriever from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain.storage import InMemoryStore from langchain_community.vectorstores import FAISS # 创建父子分块器 parent_splitter = RecursiveCharacterTextSplitter(chunk_size=2000, chunk_overlap=200) child_splitter = RecursiveCharacterTextSplitter(chunk_size=256, chunk_overlap=32) # 使用统一的向量存储 vectorstore = FAISS.from_documents([], embeddings) store = InMemoryStore() parent_retriever = ParentDocumentRetriever( vectorstore=vectorstore, docstore=store, child_splitter=child_splitter, parent_splitter=parent_splitter, ) # 添加原始文档 parent_retriever.add_documents(all_docs) # 检索时返回父文档 retrieved_parent_docs = parent_retriever.invoke("产品的保修期是多少?") for doc in retrieved_parent_docs: print(f"父文档内容长度: {len(doc.page_content)}")这个方案在长文档问答场景非常好用。比如一份 30 页的用户手册,第 12 页第 3 节讲了保修期限,但保修期限还依赖第 2 页的总则定义。如果只用小 chunk 召回,模型只知道“保修期 12 个月”,却不知道“保修期的计算起点是发货日”。用父子文档方案,模型能拿到 2000 字左右的完整章节,回答自然可靠很多。
5.5 组装高级检索管道
把上面的技术组合起来,我们可以组成一个完整的高级检索管道:
from typing import List from langchain_core.documents import Document from sentence_transformers import CrossEncoder from langchain_community.retrievers import BM25Retriever class AdvancedRetriever: def __init__(self, vectorstore, chunks, embeddings, rerank_model="BAAI/bge-reranker-v2-m3"): self.bm25_retriever = BM25Retriever.from_documents(chunks) self.bm25_retriever.k = 20 self.vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 20}) self.reranker = CrossEncoder(rerank_model) def deduplicate(self, docs: List[Document]) -> List[Document]: seen = set() unique_docs = [] for doc in docs: if doc.page_content not in seen: seen.add(doc.page_content) unique_docs.append(doc) return unique_docs def retrieve(self, query: str, top_k: int = 3) -> List[Document]: bm25_docs = self.bm25_retriever.invoke(query) vector_docs = self.vector_retriever.invoke(query) candidates = self.deduplicate(bm25_docs + vector_docs) pairs = [(query, doc.page_content) for doc in candidates] scores = self.reranker.predict(pairs) scored_docs = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True) return [doc for doc, _ in scored_docs[:top_k]]使用时非常简单:
retriever = AdvancedRetriever( vectorstore=vectorstore, chunks=chunks, embeddings=embeddings, ) docs = retriever.retrieve("A-100 产品的维护周期", top_k=3)这样一个检索管道把查询改写、混合召回、去重、Rerank 精排全部串起来了。你在真实项目里可以根据实际情况裁剪:没有 Rerank 模型资源,就做混合检索;场景比较简单,只要查询改写加父子文档也可以。
6. 运行结果与效果验证
光把代码写出来还不够,关键是怎么知道系统真的变好了。我建议从两个维度验证:
检索质量验证:
设计一组测试问题,覆盖三种类型:
- 文档中原文出现过的问题;
- 需要跨段落整合才能回答的问题;
- 用户表达模糊、与文档用词不一致的问题。
然后分别使用基础检索和高级检索跑一遍,对比 Top-3 召回结果的相关性。
端到端问答验证:
把召回的文档块喂给大模型,人工判断回答是否准确、是否有幻觉、是否引用了正确内容。这一步可以做成离线评估集,定期回归。
下面是一个最简单的验证脚本:
test_queries = [ "A-100 维护周期是多少?", "保修期的起始日怎么计算?", "去年利润情况怎么样?", ] for query in test_queries: print(f"=== Query: {query} ===") docs = retriever.retrieve(query, top_k=2) for doc in docs: print(f"- {doc.page_content[:80]}")预期效果如下:
- 基础检索:可能召回到泛泛而谈的文档块,甚至是不相关的内容;
- 高级检索:召回的文档块应该直接命中问题相关的章节,上下文完整,且排序靠前的文档确实与问题高度相关。
如果召回结果依然不理想,先检查这几点:
- Fix 查询改写 Prompt 是否正常;
- 检查 Embedding 模型是否与文档语言匹配;
- 检查切块参数和文档结构,看 chunk 是否把关键信息切碎;
- 检查 Rerank 模型的输入长度限制,如果文档块太长,Cross-Encoder 会被截断。
7. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 安装依赖报错 ModuleNotFoundError | LangChain 版本与社区包不匹配 | 执行 pip list 查看版本 | 统一安装 LangChain 0.1/0.2 系列,按官方文档匹配 community 包 |
| 中文字符串检索效果差 | BM25 没有做中文分词 | 检查 BM25 索引的分词逻辑 | 先使用 jieba 分词,再构建 BM25 索引 |
| FAISS 保存后加载报错 | 版本序列化不兼容 | 查看加载堆栈对应 faiss 版本 | 升级到统一版本,或使用 JSON 导出重新构建索引 |
| 检索召回的文档与问题无关 | 切块太大或 Embedding 选型不合适 | 人工抽样检查 Top-20 召回内容 | 调整 chunk_size,或换用更适配业务场景的 Embedding 模型 |
| 回答了但答案错误 | Prompt 没有要求模型只能依据检索内容回答 | 查看最终 Prompt 中的文档引用 | 在 Prompt 中明确“只能基于检索内容回答,不要编造” |
| 检索结果排序不稳定 | 向量检索分数与 BM25 分数融合方式不统一 | 打印两路召回分数分布 | 改用 Rerank 模型统一排序 |
| 父子文档检索返回超长内容 | parent_splitter 过大 | 查看父文档长度 | 缩小 parent chunk_size,控制上下文输入长度 |
| API 调用超时 | 查询改写或生成阶段调用大模型耗时长 | 检查链路时延分布 | 缓存改写结果,或把改写与生成拆成异步任务 |
一个问题如果按表格排查后仍未解决,先从“最小可复现案例”开始,也就是用一小段文档、一个查询、一次检索,把链条逐步打印出来,看是在哪一步丢失了关键信息。这条排查路径比盲目调参高效很多。
8. 最佳实践与工程建议
8.1 切块策略先看文档结构,再看固定长度
切块不要一上来就用固定长度。如果文档有层级结构(Markdown 标题、PDF 章节、Word 大纲),先按结构切,再对过长段落做二次切分。这样能最大程度保留语义单元。如果文档本身就是长段落,再用 chunk_size + chunk_overlap 兜底。
8.2 Embedding 选型看语言和领域
中文业务优先选择中文优化的 Embedding 模型;多语言场景用支持多语言的模型。不要只凭模型参数量大小做决定,用一段真实业务数据跑一个召回对比,比看榜单数字更有说服力。
8.3 高级检索是组合方案,不是单一技巧
查询改写、混合检索、Rerank、父子文档召回,这几个技术是叠加生效的。不同业务可以裁剪:
- 文档量大、用户提问随意 → 查询改写 + Rerank 收益最高;
- 文档结构复杂、关键信息分散 → 父子文档召回收益最高;
- 文档关键词密集、专有名词多 → 混合检索收益最高。
8.4 安全与权限
RAG 系统上线前,一定要确认知识库内容的使用权限。检索链路可能访问内部文档,如果这些文档包含敏感信息,必须在索引和检索接口上做权限控制,防止越权访问。另一个容易忽略的安全问题是 Prompt 注入:文档内容本身可能包含恶意指令,用户提问也可能夹带“忽略前面的指令”之类的攻击。生产系统需要增加输入过滤、敏感信息检测和输出审核。
8.5 评估体系是 RAG 工程的地基
每次改动检索策略、切块参数、Embedding 模型时,都要有可量化的评估指标:召回率、命中率、答案相关性、幻觉率。建议维护一个 50~200 条的评测集,每次改动后跑一遍,对比前后效果。没有评测集支撑的 RAG 开发完全是盲人摸象。
8.6 生产环境注意选型
FAISS 适合原型,生产环境建议使用支持高并发、滚动更新、权限控制的向量数据库,比如 Milvus、Qdrant、Elasticsearch。同时要把 Embedding 模型和 Rerank 模型做成独立服务,避免每次请求都加载模型;查询改写和生成层的大模型调用要加缓存,降低成本和时延。
9. 总结与后续学习方向
这篇文章从 RAG 的基本原理讲起,重点放在检索链路的工程化升级上。基础检索解决了“有没有”的问题,高级检索解决的是“准不准”和“全不全”的问题。
现在你应该掌握的技能是:
- 理解 RAG 的 Index / Retrieve / Generate 三段式架构;
- 知道切块大小、Embedding 选型、检索排序三个环节如何影响效果;
- 能够用代码实现查询改写、BM25 与向量混合检索、Rerank 精排、父子文档召回四种高级检索策略;
- 面对召不回、召回错、答不对三类问题,有清晰的排查路径。
如果这篇文章对你起到了“从 0 到 1”的引导作用,建议下一步继续深入三个方向:
- Agentic RAG:把检索从一步变成多步,让大模型根据初步结果决定是否继续搜索、是否需要查数据库、是否需要调用工具,适合复杂推理类问答;
- 知识图谱与向量数据库结合:用实体关系补充纯向量的语义缺失,解决“多跳问题”和“关系型问题”,这是当前 RAG 领域一个非常有潜力的方向;
- RAG 效果评估与监控:建立评测集、评测指标和线上监控体系,把 RAG 从“能跑”推向“稳跑”。
RAG 入门容易,精通很难。难的不是技术有多复杂,而是每个环节都需要用真实场景和数据反复调优。建议你拿一份真实业务文档,按本文的步骤完整跑一遍,再试着自己优化检索质量。等你能解释清楚“为什么这个参数改小了,召回结果反而变差了”的时候,你对 RAG 的理解就已经超过大多数只跑过 demo 的人了。