1. 为什么“RAG 烂大街”是个伪命题
这两年但凡跟大模型沾点边的项目,十有八九会把 RAG 挂在嘴边。招聘 JD 里写“熟悉 RAG 检索增强”,技术分享里讲“我们基于向量库搭建了知识问答”,开源社区里各种“十分钟手搓 RAG”的教程满天飞。结果就是很多人产生了一种错觉:RAG 已经没什么可做的了,无非就是文档切片、向量化、存库、检索、拼 Prompt 这几步,谁都会。
但真正在生产环境里跑过 RAG 的人心里都清楚,那条“流水线”确实烂大街了,可它从来不是决定效果好坏的关键。我见过太多团队用着同一套 LangChain 模板,有人做出来的问答准确率能到 85% 以上,有人连 50% 都费劲。差距不在流水线本身,而在六个容易被忽略的分水岭上。这篇文章就把这六处拆开讲透,从 Contextual Retrieval 到 GraphRAG,从 Agentic RAG 到 LangGraph 的工具调用编排,每一处都给出可复现的思路和踩坑记录。
如果你正在做 RAG 项目,或者正准备从零搭一个知识库,又或者你已经搭完了但效果始终上不去,那这篇内容应该能帮你省下不少试错时间。我不打算重复那些“什么是向量数据库”的基础科普,而是直接进入从业者视角,聊那些真正拉开差距的细节。
2. 分水岭一:文档切片不是切菜,Contextual Retrieval 才是解药
2.1 朴素切片的致命伤:语义断裂
几乎所有 RAG 教程的第一步都是“把文档切成 chunk”。最常见的做法是按固定字符数切,比如 500 字一段,重叠 50 字。这个方案在 demo 阶段看起来没问题,因为 demo 用的文档通常结构清晰、段落独立。但真实场景里的文档是什么样?PDF 里有跨页表格,Word 里有嵌套列表,Markdown 里有长代码块,还有那种一个论点横跨三四个段落的学术文章。
固定窗口切片最大的问题是语义断裂。举个例子,一份产品手册里写着“该接口的默认超时时间为 30 秒”,这句话本身很短,切片后可能单独成块。但用户问“超时时间怎么改”,检索到的这个块里只有“默认 30 秒”,却没有上下文说明这是哪个接口、在哪个配置文件里改。模型拿到这个块,只能瞎猜或者拒答。
我早期做企业知识库时就踩过这个坑。当时用 512 字符窗口切一份技术文档,结果用户问“鉴权失败怎么排查”,检索出来的块全是零散的报错码,因为排查步骤被切到了相邻的块里。后来我把窗口调到 1024,重叠加到 200,召回率确实上去了,但新的问题来了:块太大,噪声也多,模型经常被无关内容带偏。
2.2 Contextual Retrieval 的核心思路
Contextual Retrieval 这个思路,本质上是在切片之前先给每个块“补上下文”。具体做法是:对每个 chunk,用一个小模型(比如 7B 级别的本地模型)生成一段简短的上下文说明,然后把这段说明拼接到 chunk 前面再去做向量化。这样检索时,块本身就携带了“我来自哪份文档、属于哪个章节、在讲什么主题”的信息。
举个实际操作的例子。假设原始文档是一份 API 文档,某个 chunk 内容是:
默认超时时间为 30 秒,可通过 timeout 参数覆盖。直接向量化,这个块的信息量很低。但如果用 Contextual Retrieval 处理,小模型会生成类似这样的上下文:
本文档为支付网关 API 说明,当前章节为“订单查询接口”,以下内容描述该接口的超时配置。拼接后变成:
本文档为支付网关 API 说明,当前章节为“订单查询接口”,以下内容描述该接口的超时配置。默认超时时间为 30 秒,可通过 timeout 参数覆盖。再去做 embedding,检索“订单查询超时怎么改”时,这个块的匹配度会显著提升。实测下来,在技术文档场景里,Contextual Retrieval 能把召回准确率提升 15 到 25 个百分点,具体取决于文档结构的复杂度。
2.3 实操中的参数选择与成本权衡
这里有个关键权衡:给每个 chunk 都生成上下文,意味着额外的模型调用。一份 500 页的文档,切出 2000 个 chunk,就要调 2000 次小模型。如果用小模型本地跑,成本可控;如果用 API,费用会上去。我的经验是,优先用本地小模型做上下文生成,比如 Qwen2.5-7B 或者 Llama 3.1-8B,量化后单卡就能跑,生成一段 50 字左右的上下文也就几百毫秒。
另一个细节是上下文的长度控制。太短了没信息量,太长了又稀释了原 chunk 的权重。我一般控制在 30 到 80 字之间,具体看文档类型。技术文档可以偏短,因为章节标题本身信息密度高;法律合同可以偏长,因为需要交代条款背景。
注意:Contextual Retrieval 不是万能药。如果文档本身结构极差,比如扫描件 OCR 出来的乱码,或者聊天记录这种碎片化内容,补上下文的效果会打折扣。这种情况下,先做文档清洗比什么都重要。
3. 分水岭二:从 Naive RAG 到 Agentic RAG,检索策略的质变
3.1 Naive RAG 的天花板在哪
Naive RAG 的流程是线性的:用户提问 → 向量检索 Top-K → 拼 Prompt → 生成回答。这个流程在简单事实型问答上够用,比如“公司年假多少天”“产品保修期多久”。但一旦问题变复杂,它立刻露怯。
我总结了三类 Naive RAG 搞不定的问题。第一类是多跳问题,比如“去年 Q3 营收增长最快的产品线,它的负责人是谁”。这需要先查营收数据,再查产品线负责人,两步检索。第二类是比较型问题,比如“A 方案和 B 方案在成本上的差异”。这需要分别检索两个方案的信息,再对比。第三类是否定型问题,比如“哪些客户没有续约”。向量检索天然不擅长处理否定语义,因为“没有续约”和“续约”在向量空间里可能很近。
3.2 Agentic RAG 的决策循环
Agentic RAG 的核心变化是:把“检索”从一个固定步骤变成一个可决策的动作。模型不再是被动地接收 Top-K 结果,而是主动判断“我现在需要检索吗”“检索什么”“检索结果够不够”“要不要再检索一次”。
这个决策循环通常包含几个环节。首先是查询改写,把用户的原始问题改写成更适合检索的形式。比如用户问“那个新出的功能怎么用”,Agent 会先改写成“XX 产品最新版本新增功能的使用方法”。然后是检索执行,可能是一次,也可能是多次,每次用不同的查询词。接着是结果评估,判断检索到的内容是否足以回答问题。如果不够,就调整查询词再检索。最后才是生成回答。
LangGraph 在这类场景里特别顺手,因为它把整个流程建模成状态图,每个节点是一个动作,边是条件跳转。你可以很清晰地定义“如果检索结果置信度低于阈值,就回到查询改写节点重新来一遍”。这种显式的状态管理,比用 LangChain 的 AgentExecutor 那种黑盒循环要可控得多。
3.3 用 LangGraph 搭一个最小可用的 Agentic RAG
下面给一个简化版的 LangGraph 状态图定义,展示核心节点和跳转逻辑。代码用 Python 写,依赖 langgraph 和 langchain。
from typing import TypedDict, List from langgraph.graph import StateGraph, END class RAGState(TypedDict): question: str rewritten_query: str retrieved_docs: List[str] answer: str retry_count: int def rewrite_query(state: RAGState): # 调用小模型改写查询 new_query = llm_rewrite(state["question"]) return {"rewritten_query": new_query, "retry_count": state.get("retry_count", 0)} def retrieve(state: RAGState): docs = vector_store.search(state["rewritten_query"], top_k=5) return {"retrieved_docs": docs} def evaluate(state: RAGState): # 判断检索结果是否足够 score = llm_evaluate(state["question"], state["retrieved_docs"]) return {"score": score} def generate(state: RAGState): answer = llm_generate(state["question"], state["retrieved_docs"]) return {"answer": answer} def should_retry(state: RAGState): if state["score"] < 0.7 and state["retry_count"] < 2: return "rewrite" return "generate" graph = StateGraph(RAGState) graph.add_node("rewrite", rewrite_query) graph.add_node("retrieve", retrieve) graph.add_node("evaluate", evaluate) graph.add_node("generate", generate) graph.set_entry_point("rewrite") graph.add_edge("rewrite", "retrieve") graph.add_edge("retrieve", "evaluate") graph.add_conditional_edges("evaluate", should_retry, {"rewrite": "rewrite", "generate": "generate"}) graph.add_edge("generate", END) app = graph.compile()这个图里最关键的是should_retry这个条件边。它让整个流程有了“回头路”,而不是一条道走到黑。实际跑的时候,retry_count要设上限,不然遇到烂问题会无限循环。我一般设 2 次,超过就强制生成,哪怕结果不完美也比卡死强。
提示:Agentic RAG 的延迟会比 Naive RAG 高不少,因为多了改写和评估的模型调用。如果对响应时间敏感,可以把改写和评估都放到本地小模型上,生成再用大模型。
4. 分水岭三:GraphRAG 不是噱头,它解决的是关系型问题
4.1 向量检索的盲区:实体关系
向量检索擅长找“语义相似”的内容,但不擅长处理“实体关系”。比如你问“张三和李四在哪个项目里合作过”,向量检索会去找包含“张三”“李四”“合作”这些词的块,但如果文档里张三和李四的名字从没在同一段里出现过,只是分别出现在两个项目文档里,向量检索就无能为力了。
GraphRAG 的思路是先把文档里的实体和关系抽出来,构建一个知识图谱,然后基于图去做检索。这样“张三—参与—项目A”和“李四—参与—项目A”两条边就能被关联起来,回答“他们在项目A合作过”就顺理成章了。
4.2 实体抽取与图谱构建的实操细节
构建 GraphRAG 的第一步是实体抽取。可以用 LLM 来做,prompt 大概是“从以下文本中抽取所有人物、组织、项目、事件,以及它们之间的关系,输出 JSON 格式”。这里有个坑:LLM 抽取的实体名称经常不一致,比如“张三”和“张先生”会被当成两个实体。所以需要做实体归一化,可以用字符串相似度加规则,也可以再用一次 LLM 做消歧。
图谱存储我一般用 Neo4j,因为它的 Cypher 查询语言很直观,而且和 LangChain 的集成比较成熟。节点类型包括实体和文档块,边类型包括“提及”“参与”“属于”等。检索时,先用向量检索找到相关实体,再沿图谱扩展一跳或两跳,把关联的文档块拉出来。
4.3 GraphRAG 的适用边界与成本
GraphRAG 不是所有场景都值得上。它的构建成本比 Naive RAG 高一个数量级,因为要对全量文档做实体抽取和关系构建。我一般只在两种情况下考虑:一是文档之间存在大量交叉引用,比如法律条文、专利文献;二是用户问题经常涉及多实体关系,比如“哪些供应商同时给 A 和 B 产品供货”。
如果只是简单的单文档问答,GraphRAG 的投入产出比很低。我见过有团队为了“技术先进性”硬上 GraphRAG,结果图谱构建花了两周,检索效果还不如调优后的向量检索。所以选型时一定要先分析用户问题的类型分布,别为了用而用。
5. 分水岭四:检索后的重排序,被低估的提效环节
5.1 为什么 Top-K 检索结果需要重排
向量检索返回的 Top-K 结果,是按向量相似度排序的。但向量相似度高不等于对回答问题有用。我经常遇到的情况是:排名第一的块确实和问题语义最像,但它只是泛泛而谈;排名第五的块反而包含了具体答案,但因为用词不同,向量相似度稍低。
重排序模型(Reranker)就是来解决这个问题的。它把问题和每个候选块一起输入一个交叉编码器,输出一个相关性分数,然后按这个分数重新排序。交叉编码器比双塔向量模型更准,因为它能捕捉问题和文档之间的细粒度交互。代价是计算量大,不能对全库做,只能对检索返回的 Top-K 做。
5.2 重排序模型的选型与部署
常用的重排序模型有 BGE-Reranker、Cohere Rerank、Jina Reranker 等。我实测下来,BGE-Reranker-v2 在中文场景表现很稳,而且可以本地部署,用 ONNX 或 TensorRT 加速后,对 20 个候选块做重排也就几十毫秒。
部署时有个细节:重排序模型和向量模型最好用同一个系列,比如都用 BGE 系列,这样语义空间比较一致。如果向量用 OpenAI 的 embedding,重排序用 BGE,有时候会出现分数分布不匹配的问题,需要额外做归一化。
5.3 重排序的收益与延迟权衡
重排序的收益在复杂问题上特别明显。我做过一个对比测试,同一套向量检索,不加重排序时回答准确率 62%,加了 BGE-Reranker 后提升到 78%。但延迟从 800ms 增加到了 1.2 秒。如果对延迟敏感,可以只对 Top-10 做重排,而不是 Top-20,这样延迟增加控制在 200ms 以内。
注意:重排序不是越多越好。如果检索本身召回率就很低,重排序也救不回来。它是在召回结果里挑更好的,不是从无到有创造相关性。所以召回和重排要一起调,别只盯着一个。
6. 分水岭五:知识库的更新与版本管理
6.1 增量更新为什么容易出问题
很多 RAG 项目上线后,知识库就“冻”住了。文档更新了,向量库还是旧的。等发现回答过时了,才想起来重新全量跑一遍。全量重建的问题很明显:耗时长、成本高、期间服务不可用。
增量更新的难点在于:怎么判断哪些文档变了、哪些块需要删、哪些需要加。如果只是简单地把新文档追加进去,旧版本的块还在库里,检索时可能同时召回新旧两个版本,模型就会精神分裂。我见过一个案例,产品价格改了,但旧价格的块没删,用户问价格时,模型一会儿说 199,一会儿说 299,体验极差。
6.2 基于文档指纹的增量方案
我的做法是给每个文档算一个指纹,比如用文件内容的 SHA256,再加上最后修改时间。每次更新时,对比指纹,只处理变化的文档。对于变化的文档,先按文档 ID 删除旧的块,再插入新的块。这样保证库里同一个文档只有一个版本。
向量库一般支持按 metadata 过滤删除,比如 Chroma 的where={"doc_id": "xxx"},Milvus 的delete(expr="doc_id == 'xxx'")。删除后再插入,注意要等删除操作生效再插入,不然可能查到旧数据。有些向量库是最终一致的,删除后需要等几秒。
6.3 版本回滚与灰度发布
生产环境里,知识库更新最好有回滚机制。我的做法是每次更新前,把当前向量库快照一份,或者至少记录下当前版本的文档指纹列表。如果新版本上线后效果变差,可以快速切回旧版本。
灰度发布也值得做。可以先更新 10% 的文档,观察一周的问答质量,没问题再全量。LangGraph 里可以用条件边来实现“根据文档版本路由到不同检索库”,这样灰度切换很平滑。
7. 分水岭六:评估体系,没有它就是在盲人摸象
7.1 为什么 RAG 评估比传统 NLP 难
传统 NLP 任务有明确的标签,分类看准确率,翻译看 BLEU。RAG 的评估复杂得多,因为它涉及检索和生成两个环节,而且“好答案”往往不唯一。用户问“怎么退款”,回答可以是步骤列表,也可以是流程图,还可以是客服电话,都算对。
更麻烦的是,RAG 的错误会级联。检索错了,生成肯定错;检索对了,生成也可能跑偏。所以评估必须分开看:检索的召回率、准确率,生成的忠实度、相关性。
7.2 一套可落地的评估指标
我常用的评估指标分三组。检索侧看Hit Rate(Top-K 里有没有正确答案所在的块)和MRR(正确答案排在第几位)。生成侧看Faithfulness(回答是否忠于检索内容,有没有编造)和Answer Relevance(回答是否切题)。端到端看人工评分,抽样 100 条,按 1 到 5 分打分。
Faithfulness 可以用 LLM 来评,prompt 是“以下回答是否完全基于给定文档,有没有添加文档中没有的信息”。这个自动化指标和人工评分的相关性挺高,能省不少人力。
7.3 评估集的建设与迭代
评估集不是一次建完就完事。我一般分三个阶段。冷启动阶段,人工构造 50 到 100 个问题,覆盖主要场景。上线后,从真实用户日志里采样,把那些回答差的问题加进评估集。迭代阶段,每次改动检索或生成策略,都在评估集上跑一遍,看指标有没有退步。
评估集要定期更新,因为用户问题分布会变。我见过一个项目,评估集还是半年前建的,结果新功能上线后,评估集里根本没有相关问题,指标看着挺好,实际用户体验很差。
8. 常见问题与排查技巧实录
8.1 检索召回率低怎么排查
先看查询词和文档用词是否一致。用户问“怎么退钱”,文档写“退款流程”,向量模型如果没训好,这两个可能匹配不上。解决办法是加一层查询改写,把口语化问题转成文档风格的关键词。
再看切片粒度。如果块太小,信息不完整;块太大,噪声多。可以做个实验,用不同窗口大小跑评估集,看 Hit Rate 的变化曲线,找拐点。
最后看 embedding 模型是否适合当前领域。通用 embedding 在医疗、法律等垂直领域往往不够用,可以考虑用领域数据微调,或者换用领域预训练的模型。
8.2 生成答案编造内容怎么治
编造通常是因为检索到的内容不够回答,模型就自己“脑补”。解决办法有两个。一是在 prompt 里明确写“如果文档中没有相关信息,请回答不知道”,并且给几个 few-shot 例子。二是加一个后置校验,用 LLM 检查回答里的每个事实是否能在检索文档里找到依据,找不到就删掉或标记。
还有一个容易被忽略的点:温度参数。生成时温度设太高,模型容易发挥。RAG 场景我一般设 0.1 到 0.3,保证稳定性。
8.3 响应太慢怎么优化
先定位瓶颈。是检索慢、重排慢,还是生成慢?检索慢通常是向量库索引没建好,或者候选集太大。重排慢可以换更小的模型,或者减少候选数量。生成慢就换更快的推理框架,或者用流式输出让用户先看到部分内容。
缓存也很有效。常见问题的问题-答案对可以缓存,下次直接返回。语义缓存更高级,用向量相似度判断新问题是否和缓存问题等价,等价就直接返回缓存答案。
| 问题现象 | 可能原因 | 排查动作 | 解决方向 |
|---|---|---|---|
| 召回率低 | 查询词不匹配 | 对比查询词和文档用词 | 加查询改写 |
| 召回率低 | 切片粒度不当 | 跑不同窗口的评估 | 调窗口和重叠 |
| 答案编造 | 检索内容不足 | 检查 Top-K 是否含答案 | 加拒答 prompt |
| 答案编造 | 温度过高 | 检查生成参数 | 降低温度 |
| 响应慢 | 重排耗时 | 计时各环节 | 换小模型或减候选 |
| 响应慢 | 生成耗时 | 计时各环节 | 流式输出或换框架 |
8.4 多轮对话场景的特殊处理
多轮对话里,用户的问题经常省略主语。比如第一轮问“A 产品的价格”,第二轮问“那 B 呢”。如果直接把“那 B 呢”拿去检索,肯定召回不到东西。所以需要做查询补全,把历史对话和当前问题拼起来,改写成完整问题再检索。
LangGraph 里可以用一个节点专门做这件事,把对话历史作为状态传进去。改写时注意保留指代关系,别把“那 B 呢”改成“B 产品的价格是多少”就丢了“价格”这个关键信息。
9. 我个人的一些实操体会
做了这么多 RAG 项目,我最大的体会是:别一上来就追求架构先进。很多团队看到 Agentic RAG、GraphRAG 这些词就兴奋,恨不得全用上,结果基础检索都没调好,上层再花哨也是空中楼阁。我的建议是先把 Naive RAG 的召回率和忠实度做到 80 分,再考虑加 Agent 决策或图谱增强。
另一个体会是评估要趁早。没有评估集,你根本不知道改动是变好还是变坏。我一般项目启动第一周就把评估集建起来,哪怕只有 30 个问题,也比没有强。后面再慢慢扩充。
最后分享一个小技巧:把检索到的块和生成的答案一起展示给用户。很多产品只展示答案,用户没法判断可信度。如果答案旁边能展开“依据来源”,用户自己就能判断靠不靠谱。这个功能实现起来不难,但对信任感的提升很大。
这个方向后续还可以往多模态 RAG 扩展,比如知识库里存图片、表格,检索时同时召回文本和图像。不过那是另一个话题了,等有机会再聊。