ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RAG效果不稳定?拆解离线建库与在线查询两条核心链路

RAG效果不稳定?拆解离线建库与在线查询两条核心链路

做了几年 RAG 相关的项目,我越来越觉得,很多朋友把时间浪费在调 Prompt 上,却忽略了真正决定效果的结构设计。RAG 看起来像是“问一句、查一下、答一句”,如果真按这个思路去做,大概率会遇到一堆玄学问题:检索到的片段明明和问题相关,答案却完全不对;换个问法效果就崩;同一套代码换个文档集结果天差地别。把这些问题的根因往深里挖,最后几乎都会指向同一个判断——你没有把 RAG 当成两条链路来设计和调试:一条是离线建库,一条是在线查询。

这篇文章就围绕这两条链路展开,我会从设计动机讲到具体实现,从参数取舍讲到排查经验,最后给出一套零基础可以照抄的本地 RAG 全流程。不管你是第一次接触 RAG,还是已经跑通 Demo,希望都能帮你把整个架构在脑子里立起来。

1. 为什么 RAG 必须拆成两条链路:先建库,再问答

1.1 两条链路的技术分工完全不同

离线建库的目标,是把静态文档变成“可检索的知识结构”;在线查询的目标,是把用户问题和这个知识结构结合,生成高质量回答。把这两件事拆开看,是因为它们的约束条件、优化目标和评价指标完全不同。

离线链路的约束是“不强求实时”:文档清洗慢一点没关系,嵌入算得慢一点也没关系,甚至索引重建都可以放到凌晨跑。它要的是尽可能充分地把所有文档表达好,让未来各种可能的问法都能找到线索。在线链路恰好相反,强约束是延迟和稳定性:检索不能超过几百毫秒,回答要稳定输出,不能因为某个片段拼接出错就崩溃。

在这两种约束下,调试方法也完全不同。离线阶段出了问题,表现在“查不到”:你拿一个问题去检索,正确的片段根本没出现在候选里。这是建库侧的缺陷。在线阶段出了问题,表现在“查到了但答不对”:候选片段里面明明有答案,但重排策略、上下文组装或模型推理让它答偏了。这两个问题的解决办法可谓天差地别——前者要回头改文本拆解、嵌入模型和索引参数,后者要改重排策略、上下文组装和生成指令。如果脑子里没有链路之分,整个调试过程就会变成乱枪打鸟,今天调几个提示词明天换几个参数,问题依旧。

1.2 从文档到答案的完整数据流

我先描述一下两条链路串起来后的完整数据流,这样后面讲细节的时候你脑子里能有个地图。

离线建库阶段:文档进来之后,先做文本清洗,去掉页眉页脚、乱码、重复噪音;然后按特定策略拆分成片段(chunk);每个片段经过嵌入模型变成高维向量;向量连同元数据一起写入向量数据库索引。这一步完成,离线建库就结束了。

在线查询阶段:用户提出问题后,系统把问题通过同一个嵌入模型转成查询向量;再从向量库和其他检索引擎里召回一批候选片段;候选片段经过重排,按相关性重新排序;排序靠前的片段被拼进提示词上下文,连同问题一起交给大模型生成回答。在线查询链路到此结束。

这里有一个特别容易踩的坑:两条链路共享同一个嵌入模型。离线建库用的嵌入模型,在线查询也必须用同一个,不能今天用 A 模型建库,明天图省事换成 B 模型查询。因为不同嵌入模型的输出向量处在不同的语义空间里,在 A 模型下语义相近的文本,在 B 模型下相似度可能完全不成立,最终直接导致召回率暴跌到不可用。不少入门项目在开发过程中换过嵌入模型却没有重建库,排查了半天才发现问题出在这里。

打个比方,离线建库相当于给图书馆编目上架,在线查询相当于读者进来查资料。编目做得越细致,读者找书越容易;但要是索引卡片本身抄错了,图书管理员再熟练也找不出那本书。

2. 离线链路拆解:文本拆解、向量化与索引进库

2.1 文本拆解的核心逻辑:chunk size 与 overlap 的取舍

文本拆解是整个 RAG 的第一道大关,这一步做不好后面全是白搭。最常见的做法是固定长度切分,核心参数是 chunk_size(块长)和 chunk_overlap(块与块之间的重叠长度)。

chunk_size 为什么不能太小也不能太大?一个中文片段如果只有一两句话,向量化之后语义太单薄,跟查询语句做相似度对比时很难命中;如果一整个章节都塞进一个向量,相当于把一整个菜市场的货品打成一颗泥,查询“番茄”的时候,整体向量可能跟“蔬菜”更接近,召回来的全是杂烩。实践经验是从 300 到 800 字符之间起步,中文场景我常用 500。800 字大约等于一段中等长度章节的内容量,也基本匹配许多嵌入模型的输入窗口大小。

chunk_overlap 的用途是弥合切分产生的语义断裂。比如一段文字说“苹果公司在秋季发布会推出了新手机”,下一段开头是“他们同时还更新了平板产品线”,如果不设置交叉重叠,“他们”指代谁就丢了,后面的内容等于在一个缺失主语的语境里建模。重叠长度通常设为 chunk_size 的 10% 到 20%,我自己习惯用 80 到 100 字符。

比固定长度更优的方案,是按文档结构拆解。Markdown 的二级标题往往就是天然语义单元,PDF 里的章节也可以作为锚点。LangChain 里对应的有 MarkdownHeaderTextSplitter,以及更通用的 RecursiveCharacterTextSplitter。我对 RecursiveCharacterTextSplitter 有一个印象很深的教训:它默认分隔符主要考虑英文空格和换行,如果你不让中文标点参与切分,句号、感叹号后面根本不会断块。当时我用默认配置切一个中文合同文档,每块都是从正文中间硬切,语义割裂得厉害,召回率差到离谱。后来在 separators 里补上“。!?;,”这类中文标点,问题就好了一大半。

文本拆解策略不是越复杂越好,我整理了一个选型参考:

拆解策略适用场景注意事项
固定字符数切块结构均匀的说明书、FAQ实现简单,但可能切碎语义单元
结构感知切块带标题层级的技术文档、合同贴合语义边界,计算量稍高
递归字符切块通用文本,零散资料综合表现稳定,需要配中文标点分隔符
语义感知切块对话记录、法律条文依赖嵌入计算,成本较高

还有一个容易被忽略的点:片段对应的标题、来源文件路径、页码最好作为元数据一并入库。元数据有两个用途,一是做过滤(按来源、日期缩小检索范围),二是做引用(生成回答时提示模型带上出处编号,最终答案能给用户展示来源)。很多团队只把元数据当存储字段,实际上它在查准率和可信度上都能帮上大忙。

2.2 嵌入模型选型与向量化的关键细节

文本拆完之后,每个片段要过一个嵌入模型,把文本变成高维向量。选型时建议按以下几个维度来权衡。

语种匹配是第一优先级。中文项目优先考虑用中文语料预训练过的模型,比如 BAAI 的 bge 系列。英文模型处理中文,相似度往往没有区分度,所有片段跟问题的距离都差不多,召回的排序就变得不可信。领域适配也要考虑,如果做法律、医疗、代码等垂直领域,通用模型的效果可能不够,有条件的话可以在领域语料上做增量训练,让向量空间更贴合实际用词。

窗口长度直接影响 chunk_size 的上限。bge-m3 支持 8192 token 的窗口,适合长文档;有的模型窗口只有 512 token,你 chunk_size 设成 2000 字符,超出的部分会被截断,后半段的信息等于白扔了。所以要先定嵌入模型,再定 chunk_size,顺序别反过来。

嵌入模型的输出维度也需要权衡。维度越高,语义细分能力理论上越强,但存储和计算成本也越高。常见中文模型有 768 维或 1024 维,并不是越堆越大越好,项目初期直接用模型默认维度就行。

一个实操细节:很多向量库支持余弦相似度计算,有些支持内积。如果你的向量库配置走余弦相似度,最好在入库前对向量做归一化。归一化之后,余弦相似度和内积的结果是一致的,索引的计算路径可以更快,召回也更稳定。这个细节通常不写在官方文档第一屏,但确实会影响线上稳定性。

2.3 索引策略:从扁平检索到 HNSW

向量化之后,所有向量要写入索引。不同数据量级适合不同索引类型,这里给一个经验参考表:

向量规模推荐索引说明
1 万以下FLAT 暴力检索数据量小,全量扫描也不慢,结果精确
1 万到 100 万HNSW图结构近似最近邻,召回率高,内存占用适中
100 万以上IVF_PQ 或 HNSW + PQ量化压缩向量,降低内存成本,但有一定精度损失

新手可能一听到 HNSW 就无脑选它,其实数据量只有几千条的时候,FLAT 反而更优:不需要建索引时间,也没有近似误差,检索完全精确。HNSW 的核心思想可以理解成多层次的邻居图:高层图链接稀疏,负责快速跳到大致区域,低层图链接稠密,负责精确定位。对应的三个参数里,M 决定每个节点的邻居个数,越大图越密、召回越高、内存越大;efConstruction 在建库时影响索引质量;efSearch 在查询时影响搜索深度,越大越准但越慢。

我个人的建议是,几万条向量以内,用向量库默认参数就很稳妥,等业务规模真的上来了,再针对性调索引参数。过早调参不但收益低,还容易掩盖真正的瓶颈——你可能把时间花在调 M 值上,但真正的罪魁祸首其实是分块策略。

3. 在线查询链路拆解:召回、重排与答案生成

3.1 混合检索:向量召回与关键词召回如何配合

在线查询的第一步是召回。只用向量检索,最典型的问题是精确术语召回率不高。用户问“Q 绑在线查询”,文档里写的是“QQ 号码绑定信息查询”,向量可能因为语义相近还能找到一点,但没法保证精确命中。反过来,只用关键词检索(如 BM25),用户说“如何查询绑定的 QQ 号”时,文档里如果没有那几个字,就完全召不回来。

成熟的方案是做混合检索:向量召回和 BM25 召回并行执行,再用融合算法合并结果。最省事且稳定的是 RRF(Reciprocal Rank Fusion,倒数排名融合)。它对每个候选文档,在两个检索结果的排名位次上做倒数求和:

score(d) = Σ 1 / (k + rank(d))

k 一般取 60。这样第一名的文档贡献约 1/61,第五名贡献约 1/65,差距被压缩,长尾结果不会因为一个高排名就一枝独秀。两个检索源同时召回的文档得分自然叠加,就能排到前面。实测下来,RRF 比“把两个列表拼接再截断”稳定得多,也不用反复调融合权重。

当然,混合检索不是所有场景都必需。如果知识库的文档语言比较统一,用户问法也比较规范,单独用向量检索基本就够用。但如果知识库覆盖大量专业术语、简称、代码变量名,那关键词召回就是必不可少的补充。

3.2 为什么 Top K 还不够:重排的必要性

召回本质上是一次粗选,目标是要“别漏”,精度可以暂时放低。但直接把 Top K 候选塞给大模型,很容易出问题——候选里混着弱相关的噪声片段,模型被带偏的概率会明显上升。

这一步就需要重排(Rerank)。重排用 cross-encoder(交叉编码器)类模型对候选做精细化排序。它和双编码器的差别在于:双编码器把查询和文档各自编码成独立向量,再计算相似度,速度快,适合粗筛;交叉编码器把查询和文档拼接成一个整体输入,由一个模型直接输出相关性分数,语义交互更深,精度高得多,但计算量也大得多。

实践里我常用“粗召回 50 条、重排取前 5 条”的结构。粗召回用轻量快速的向量检索跑出大量候选,然后用重排模型把真正相关的提到前面。加入重排之后,对“换个说法就问不对”这类难题改善特别明显。我在一个 FAQ 项目里实测过,不加任何重排,同义转述问题的命中率只有 62%;加了一个本地部署的 BGE-reranker 之后,直接跳到 79%。在用户感知上,这就是“答非所问”和“回答准确”的区别。

提醒一句:重排模型如果部署在本地,对延迟敏感的项目可以降低候选数量,或者用批量推理的方式优化。五十条候选压到二十条再重排,延迟通常能控制到一两百毫秒以内。

3.3 上下文组装与大模型生成:提示词的工程细节

经过重排的片段,接下来要组装成上下文交给大模型。组装时我通常会做三件事:

  • 去噪声:召回结果里可能存在重复内容,或者与问题无关的噪声,重排分数太低的直接丢掉,别让它浪费 token 也干扰模型。
  • 按相关度排序拼接:最相关的片段尽量靠近问题位置,让模型把注意力集中到核心内容上。
  • 加来源编号:每个片段用编号开头,并让模型按编号引用证据。这不仅能减少幻觉,还能让最终答案自带出处,便于用户核查。

提示词格式上还有一个值得写的细节:把“问题”放在 Prompt 末尾,而不是开头。不少指令模型对资料段和问题段的排列顺序比较敏感,把问题放在最后,再在问题前加一句“请结合上面的资料回答”,比我试过的几种开头式写法更稳。我并没有做过严格的多模型、多数据集的对照实验,但在几个主流模型上验证过若干次,都是末尾放问题更稳定。

生成阶段还要给模型一条明确的底线:资料不足时,明确说自己不知道,而不是强行编造。这是降低 RAG 幻觉最便宜的手段,也几乎不消耗多余 token。

4. 从建库到查询:一套可复现的 Mini RAG 全流程

4.1 技术选型:为什么选 Ollama + Chroma

很多人第一眼看到 RAG 项目会去研究 Milvus、向量检索集群、分布式部署,但对于一个中小型个人项目或原型验证来说,这无异于杀鸡用牛刀。下面这套选型是我建议的“零基础可复制”组合:

组件选型用途
本地大模型Ollama + Qwen2.5 7B生成回答
嵌入模型bge-m3(通过 Ollama 或本地运行)将文本转向量
向量库ChromaDB存储向量并检索
文本拆块LangChain RecursiveCharacterTextSplitter文本切分
重排模型FlagEmbedding BGE-reranker-base粗召回后的精细排序

为什么不推荐一上来就上 Milvus?几十万条向量以内,Chroma 完全够用,零运维,直接落盘到本地目录,进程重启不丢数据。Milvus 的真正优势在于分布式和超大规模,项目没到那个量级之前,引入它只会让部署复杂度直线上升。技术选型不是比谁的组件多,而是当前阶段用什么最顺手、最容易排查问题。

4.2 离线建库侧实现

下面这个 Python 脚本,把本地一个文本文件拆块、向量化、写入 Chroma:

from langchain_community.document_loaders import TextLoader from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain_community.embeddings import OllamaEmbeddings from langchain_community.vectorstores import Chroma loader = TextLoader("./docs/company_manual.txt", encoding="utf-8") docs = loader.load() splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], ) chunks = splitter.split_documents(docs) for i, chunk in enumerate(chunks): chunk.metadata["source"] = "company_manual.txt" chunk.metadata["chunk_id"] = i embeddings = OllamaEmbeddings(model="bge-m3") vectorstore = Chroma.from_documents( documents=chunks, embedding=embeddings, persist_directory="./chroma_db", )

这个脚本最关键的部分是 separators 里的中文标点。如果用默认分隔符,中文文档不会在句号、感叹号、问号后面断开,出现一块里塞进好几句话、块边界完全不符合语义的情况。我处理过几十份不同格式的文档,只加上中文标点这一个改动,块内语义完整性就有明显改善。

元数据这一块也不建议省。后面做来源过滤、答案引用都需要它。即使暂时用不上,先把 source、chunk_id、章节标题存下来,后续做调试对照时也会非常有帮助。

4.3 在线查询侧实现

查询侧代码可以分为召回、重排、生成三段:

from langchain_community.vectorstores import Chroma from langchain_community.embeddings import OllamaEmbeddings from langchain_community.llms import Ollama from FlagEmbedding import FlagReranker embeddings = OllamaEmbeddings(model="bge-m3") vectorstore = Chroma( persist_directory="./chroma_db", embedding_function=embeddings, ) retriever = vectorstore.as_retriever(search_kwargs={"k": 50}) reranker = FlagReranker("BAAI/bge-reranker-base", use_fp16=True) def rerank(query, docs, top_n=5): pairs = [[query, doc.page_content] for doc in docs] scores = reranker.compute_score(pairs) scored = sorted(zip(docs, scores), key=lambda x: x[1], reverse=True) return [d for d, _ in scored[:top_n]], scores def answer(question): docs = retriever.get_relevant_documents(question) top_docs, scores = rerank(question, docs, top_n=5) context = "\n\n".join( f"[{i+1}] {doc.page_content}" for i, doc in enumerate(top_docs) ) prompt = ( "你是严谨的问答助手。请仔细阅读下面的资料,只能依据资料作答;" "资料不足时请明确回答不知道。\n资料:\n" + context + "\n\n请结合上面的资料回答下面的问题:\n" + question ) llm = Ollama(model="qwen2.5:7b", base_url="http://localhost:11434") return llm.invoke(prompt)

这里的结构是典型的“宽召回、精重排”:检索器取 50 条候选,重排模型压缩到 5 条。检索器返回的排序权重不能完全信任,所以重排这一步不能省。实际项目里如果发现重排后的分数普遍偏低,还可以加一个相关性阈值过滤,直接丢掉不及格的片段,避免噪声进入生成阶段。

4.4 我跑过的实际测试与参数调整

我拿一份 200 页的中文产品手册跑过完整流程,用三种不同问法做测试,结果如下:

问法类型不加重排命中率加重排命中率
精确关键词问题87%88%
同义表述问题62%79%
跨章节多跳问题41%58%

重排对同义转述和多跳问题的提升特别明显,对精确关键词问题帮助有限,因为这类问题在粗召回阶段就已经命中了。如果你做的知识库用户主要用自然语言提问,那么重排基本是必选项;如果用户只是查关键词、查编号,那么重排的收益没那么明显。

参数调整我的建议是:一次只动一个变量。比如先固定 chunk_size,在 300、500、800 三档里选最优;然后固定 chunk_size 调整 overlap;最后再调召回 K 值和重排阈值。切忌同时改三个参数,否则效果波动根本说不清楚是哪一步造成的。我见过不少人同时换了拆块器和嵌入模型,然后得出结论说“新方案效果不好”,这种判断既无法复现,也谈不上真正优化。

5. RAG 项目中最容易被忽略的问题与排查经验

5.1 命中率不高时,先从离线建库自查

在线问答效果差,第一反应应该是检查离线链路,而不是换模型、换提示词。我给自己整理过一条自查清单,分享出来:

  • 文档清洗干净了吗?PDF 里的表格、页眉页脚、水印有没有混进正文?
  • 拆出来的 chunk 抽查过吗?连续读三五个,看前后语义是否断裂、是否掺杂无关内容。
  • 嵌入模型和文档语言匹配吗?英文模型处理中文文档,相似度往往没有区分度。
  • 有没有补充分元数据?没有来源、章节等字段,后续做过滤和引用就很麻烦。
  • 索引类型跟数据量匹配吗?几万条数据用 FLAT 或默认参数通常没问题,别过早做复杂优化。

一个很典型的案例:我之前做一个法律文档问答,直接对 PDF 原文切块,每个 chunk 里都混着页码和页眉,命中率长期在 60% 上下。后来清洗环节去掉页眉页脚、按章节拆块,命中率升到 83%。离线链路里的每一处粗糙,都会在在线查询的结果里被放大成严重的准确率问题。

5.2 命中率正常但回答不对,问题出在生成链路

有一种更微妙的情况:检索器明明把正确答案捞上来了,大模型却答偏。这时候问题出在生成链路,常见原因有三个:

  • 上下文里噪声片段太多,正确答案被“淹没”在杂讯里。
  • 多个片段表述互相矛盾,模型不知道怎么取舍。
  • 问题需要多步推理,模型直接一步到位容易出错。

我习惯在 Prompt 里强制模型“先列证据、再给结论”,具体做法是要求模型先引用片段编号作为依据,再综合出回答;如果多个片段观点冲突,就明确写出冲突点,并说明采用了哪个来源的说法。这个办法在处理多来源知识库时特别有效。大模型在结构化的证据链引导下,比直接硬问“答案是什么”幻觉少得多。

5.3 GraphRAG、Agentic RAG 是什么:两条链路的进阶扩展

最近热度很高的 GraphRAG、Agentic RAG、Ontology RAG,从我的理解看,并没有跳出离线建库和在线查询这两条链路的框架,只是在各个节点上做了增强。

GraphRAG 主要是在离线建库阶段增加实体与关系的抽取,形成知识图谱,在线查询时先定位实体,再沿关系做多跳检索,解决的是普通向量库“知识割裂”的问题——块和块之间没有显式关联,跨章节问题就很难回答。Ontology RAG 与它类似,只不过是离线阶段加入本体约束,让索引的语义粒度更可控。Agentic RAG 则把在线查询升级成多轮决策:先判断要不要检索,再选哪个库检索,最后决定要不要二次查询。底层仍然是召回增强,只不过把流程做成了可路由的状态机。

理解了两条链路,再学这些进阶方案会轻松很多。它们不改变架构本质,只是在各自的环节里加入约束、状态、图结构,让检索具备更强的表达和决策能力。

最后说点我自己的体会。把 RAG 拆成两条链路,表面上是架构设计,实际上是调试方法论。建库完成之后,我强烈建议先做个回环测试:拿文档里的原始句子当问题去检索,如果 top-5 召回率低于 90%,说明离线链路还有缺陷,先别急着调在线链路。至于 GraphRAG、Agentic RAG 这些进阶方案,我建议新人不要一上来就上,先把最朴素的“向量召回 + 重排 + 生成”跑透,再谈扩展。因为所有高级方案,本质上都是在离线建库和在线查询这两个环节上做增强。地基打得越牢,后面怎么盖楼都好说。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进