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 召回并行执行再用融合算法合并结果。最省事且稳定的是 RRFReciprocal 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 脚本把本地一个文本文件拆块、向量化、写入 Chromafrom 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, encodingutf-8) docs loader.load() splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap80, 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(modelbge-m3) vectorstore Chroma.from_documents( documentschunks, embeddingembeddings, 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(modelbge-m3) vectorstore Chroma( persist_directory./chroma_db, embedding_functionembeddings, ) retriever vectorstore.as_retriever(search_kwargs{k: 50}) reranker FlagReranker(BAAI/bge-reranker-base, use_fp16True) def rerank(query, docs, top_n5): pairs [[query, doc.page_content] for doc in docs] scores reranker.compute_score(pairs) scored sorted(zip(docs, scores), keylambda x: x[1], reverseTrue) 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_n5) context \n\n.join( f[{i1}] {doc.page_content} for i, doc in enumerate(top_docs) ) prompt ( 你是严谨的问答助手。请仔细阅读下面的资料只能依据资料作答 资料不足时请明确回答不知道。\n资料\n context \n\n请结合上面的资料回答下面的问题\n question ) llm Ollama(modelqwen2.5:7b, base_urlhttp://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 这些进阶方案我建议新人不要一上来就上先把最朴素的“向量召回 重排 生成”跑透再谈扩展。因为所有高级方案本质上都是在离线建库和在线查询这两个环节上做增强。地基打得越牢后面怎么盖楼都好说。