第 3 篇结尾留了个问题:把「检索知识库」做成一个工具,它内部到底长什么样。我见过不少人把这个工具写成一次向量查询,返回结果直接塞进上下文,然后抱怨 RAG 效果差。真实系统里,这一个工具背后是两条可以独立部署、独立演进、甚至由不同团队维护的流水线。
更麻烦的是,两条链路出问题的症状很像:用户看到的就是「答错了」。但一个错在三天前根本没把新文档编进索引,另一个错在今晚的查询改写把关键词换没了。不拆开就没法归因。
本系列第 4/15 篇 · 上一篇:工具调用协议:从 Schema 到幂等 · 下一篇:分块不是切字符,是切语义边界
为什么是「检索增强」,而不是「换个更大的模型」
模型里的知识是参数化的:训练时被压缩进权重,更新一次代价很高,且没有出处。RAG 引入的是非参数化知识——外部语料可以随时增删,命中内容能带文档 ID 和位置,答案可溯源。这个区别决定了三类场景基本只能用检索:
私有或实时数据。内部工单、昨天刚改的价目表、当前库存,模型权重里不可能有。
需要引用和审计。客服话术、合规问答、医疗或金融场景,「答案来自哪份文件第几节」往往比答案本身更重要。
长尾事实。低频专有名词、错误码、内部代号,参数化记忆本来就不可靠,硬塞进 prompt 又太贵。
反过来,检索解决不了「模型不会推理」。让它从十份财报里算同比增速,如果召回的是十段原文而模型算术不行,答案照样错。微调擅长的是改变行为分布——输出格式、领域语气、稳定的任务模式;RAG 擅长的是注入事实。两者不互斥,但我见过太多「效果不好就去微调」的案例,最后发现问题是文档解析时表格被压成了一坨乱文本。
一句话判断:如果错误表现为「不知道某个事实」,查检索链路;如果表现为「知道材料但答得不符合要求」,查 prompt、模型和生成约束。
离线链路:索引是一份有版本的数据产品
1 | 数据源 → 采集 → 解析/清洗 → 切分 → 元数据挂载 → 嵌入 → 索引 → 发布 |
离线链路的特点是「错了不会立刻被发现」,所以每一步都要留下可复算的中间产物。我的做法是把清洗后的文档、切分后的 chunk、以及最终索引分别存成独立快照,用同一个 corpus_version 串起来。这样某次召回异常时,可以直接把 chunk 快照拉出来看,判断是切分问题还是向量问题,不用重新爬一遍源站。
几个容易被低估的环节:
解析与清洗。PDF 的双栏、跨页表格、页眉页脚,HTML 的导航和相关阅读,Markdown 的目录块,这些混进语料会成为高相似度噪音。个人博客那次经历里,我清洗掉三成非正文 URL 之后,检索质量的变化比换嵌入模型明显得多。
元数据挂载。这一步决定后面能不能做过滤和时效处理。至少要有 doc_id、source_uri、section_path、published_at / updated_at、权限标签(acl)、corpus_version。元数据最好在切分时挂到每个 chunk 上,而不是查询时再去关联文档表——过滤要在召回阶段生效,事后过滤会让 top_k 悄悄变小。
增量与一致性。新增文档走增量写入,删除和更新必须靠 doc_id 精确失效。我踩过一个坑:文档改版后按内容哈希生成了新的 doc_id,旧 chunk 没删,检索时新旧两版一起召回,模型给出自相矛盾的结论。现在 doc_id 一律用稳定标识,内容变化只升 revision。
全量重建。换嵌入模型、改切分策略、调分词器,都必须重建索引,新旧向量不能混在同一个索引里。因此上线前要预留双索引并行的能力:新索引在影子环境跑评测,通过后原子切换 active_version。
在线链路:每一跳都在损失信息
1 | query |
这条链路最常见的设计错误是「每跳都取 top_k」。第一跳召回 20,融合后取 10,重排后取 5,构造时再砍到 3——如果第一跳的真实答案排在第 21 名,后面所有环节都没有机会。我的默认配置是召回深度远大于最终上下文容量:每路召回 50 到 100,融合后 30 到 50 进重排,最终只送 5 到 10 个片段。宽进窄出,把精度判断尽量往后放,因为越往后的环节越贵但也越准。
后置校验经常被省略,但它是唯一能兜住「引用幻觉」的地方。生成完成后检查答案里引用的 chunk_id 是否真的出现在送进去的上下文里,不在就重生成或直接降级为「无法确认」。这个检查很便宜,收益却很大。
延迟预算怎么分
一个面向用户的 RAG 请求,端到端预算通常要控制在 1 到 3 秒(首 token 更早)。我按大致比例切:
| 环节 | 典型量级 | 可控手段 |
|---|---|---|
| 查询改写(若用模型) | 100–400ms | 小模型、可缓存、可跳过 |
| 多路召回 | 20–80ms | 并发执行,ANN 参数 |
| 重排序 | 50–300ms | 候选条数、batch、模型尺寸 |
| 上下文构造 | <10ms | 纯代码 |
| 生成首 token | 300ms 起 | 上下文长度、模型、流式 |
这里最大的变量其实是生成,而不是检索。上下文每多塞 1000 token,首 token 延迟和成本都跟着涨,所以第 10 篇讲的上下文压缩不只是质量问题,也是延迟和成本问题。
如果查询改写也要调一次大模型,链路里就有两次模型调用,长尾延迟会很难看。我现在的默认是不做改写,只在检测到明确信号时才触发:查询过短(少于 4 个字)、含多个并列子问题、或首轮召回置信度普遍偏低。无条件改写常常把用户精确输入的错误码「优化」成自然语言,反而把唯一能命中的字面线索洗掉了。
故障归因:先看症状落在哪一跳
答错的时候别急着换模型,先按症状定位:
| 症状 | 高概率环节 | 验证方法 |
|---|---|---|
| 相关文档明明存在却排不进前 20 | 召回 / 切分 / 嵌入 | 直接查 chunk 快照,看目标文本是否在库里、被切成什么样 |
| 命中了正确片段但答案不用它 | 上下文构造 / 生成 | 打印实际送进模型的 prompt,检查片段位置和顺序 |
| 精确编号、错误码查不到 | 纯向量的分词与表示 | 试一次纯字面检索,对比两路结果 |
| 新旧结论互相打架 | 离线一致性 / 元数据 | 检查 doc_id 是否重复、时效字段是否缺失 |
| 语料里没有的问题也答得头头是道 | 拒答策略缺失 | 用「无答案」问题集测,加阈值或两阶段判断 |
| 换个说法就查不到 | 查询侧词汇鸿沟 | 对比原查询与口语化查询的召回列表 |
这张表背后是一个很朴素的工程习惯:把每一跳的输入输出都记到 trace 里。至少要能回答三个问题——库里有没有、召回有没有、送进模型了没有。没有 trace 的 RAG 系统,调参基本靠玄学。
两条链路之间的耦合点
离线和在线不是完全独立的,至少有三处契约必须对齐:
- 嵌入模型与维度。查询向量必须用与索引完全相同的模型和预处理(归一化、query 前缀、截断长度)。换模型时忘记同步在线侧,症状是所有查询相似度集体下降,但系统不会报任何错。
- 切分策略与上下文构造。如果离线用了 small-to-big(用小 chunk 检索、返回大 chunk 阅读),在线侧必须知道父文档的映射关系,这个映射属于索引的一部分。
- 元数据 schema。在线侧的过滤表达式依赖字段名,离线侧加字段或改类型时要有版本协商,否则过滤条件会静默失效——比报错危险得多。
我用一个 index_manifest 把这三件事写死,在线服务启动时读取并校验,不匹配就直接拒绝启动,而不是带着错误配置优雅地跑下去。
优劣与边界
RAG 的核心优势是知识可更新、可溯源、可按权限过滤,而且不需要为每批新数据重新训练。代价是引入了一整套数据工程:解析、切分、索引、重建、评测、监控,系统的故障面比「一次模型调用」大一个数量级。效果上限也不在模型,而在语料质量和检索质量——语料里没有的东西,再好的生成也变不出来。
不适用的时候也清楚:语料极小且稳定(几页文档直接放进 system prompt 更省事)、任务本质是风格转换或推理而非事实问答、以及要求极低延迟且答案集合固定的场景。还有一种容易被忽略的情况:语料本身互相矛盾且没有权威标记,RAG 会把矛盾一起喂给模型,输出看起来比纯模型更不可信。这时候该做的是数据治理,不是调检索。
后面五篇会沿着在线链路逐跳深入:切分、嵌入、向量库、混合召回、重排序。它们分别对应上表里的不同症状。你现在这条链路,能不能在五分钟内打印出某次回答「实际送进模型的那几个片段」?如果打不出来,先补 trace,比调任何参数都优先。
评论