有一次排查一个答错的 case,检索结果是全对的,五段内容都在,标准答案确实在第三段里。我把这五段原封不动喂给模型,它答对了。区别只在于顺序:线上是按相似度从高到低拼的。
那天之后,我不再把 RAG 当成一条检索流水线。它更像是在给模型排版一份临时材料。
切分决定上限
切分是最不性感的部分,也是最难回头改的部分。索引建好、跑了几十万条数据之后,再想改切分策略,等于重建整个知识库。所以它值得在项目早期多花几天。
固定长度切分的优点是简单,缺点是它不理解文档。一个条款的正文和它的例外被切到两个块里,检索命中前半段,模型就用前半段回答,语气还很确定。这种错误最难发现,因为读起来通顺,只是不完整。
按语义或递归切分好一些,优先按标题、段落、句子下刀。重叠窗口能缓解边界丢信息的问题,比例一般给到一到两成。代价是索引冗余,同一段文字被存了多次,检索结果的多样性会下降,去重又要在召回后补一层。
切分策略要按文档类型分开。法律条文和 API 文档适合按层级切,一个条款、一个接口一个块;会议纪要和客服记录没有清晰层级,按说话人或者时间窗切更自然;产品手册图文混排,得先把图注和图关联起来。用一套参数处理所有类型,结果通常是对哪一类都不够好。
具体的块大小没有通用值。中文和英文的字符信息密度不同,代码和技术文档的语义边界又不一样。照搬别人给的数字,往往只是把别人的坑重走一遍。我的做法是从一个中间值开始,拿评测集看两类错误:块太大导致的噪声,块太小导致的上下文缺失,往错误少的方向调。
多栏 PDF 的阅读顺序也常出错。按坐标提取文本,很容易把左右两栏交错拼成一段,语义全乱。这类文件我宁可用版面分析工具先还原阅读流,再切分,慢一点但结果可信。
我后来更常用的是小块索引、大块召回。索引用很细的粒度,命中之后回溯它所属的章节,把更完整的上下文交给模型。这样检索的灵敏度和生成的信息量可以分开调,不必在一个块大小上妥协。代价是要维护块与父块的映射,删除文档时要级联清理,否则会召回孤立的片段。
表格要单独处理。直接 OCR 再切,行列关系就没了,模型看到一串数字,不知道哪一列是金额。我的做法是给表格生成一段自然语言摘要,用摘要去建索引,命中之后把原始的结构化表格喂给模型,而不是喂切碎的文本。摘要只负责被检索到,回答仍然依据原表。
标题层级要当成元数据挂在每个块上。块里只有“价格为 500 元”这句话,是无法判断它属于哪个产品的;挂上章节路径之后,检索的相关性会明显好一些。这件事成本很低,收益比我预想的大。同理,文档来源、版本、更新时间、权限标签,都应该在切分时一起写进去,后面所有过滤都依赖它们。
解析环节的失败率常被低估。同一批 PDF 里总有几份是扫描件,或者带奇怪的字体编码,解析出来是乱码。我现在的做法是把解析结果先做一次质量检查,字符比例异常或者空文本过多的,直接进人工队列,不让它们静默入库。脏数据进去以后不会报错,只会在几个月后变成一个谁也说不清的 bad case。
重复文档也要处理。同一份制度在共享盘里存了五份,切完以后就是五套几乎相同的向量,检索时全被召回,把真正有差异的内容挤出去。去重不能只看文件名,得看正文的相似度,同时保留最新版本。
代价是整条数据管线变重。解析、摘要、元数据抽取,每一步都可能失败,而且要支持重跑。我在这上面花的时间,比调 embedding 模型多得多。
重排买的是精度,花的是延迟
检索阶段的目标是别漏,重排阶段的目标是别错。这两件事应该分开做。
双塔结构可以把文档向量离线算好,查询时只算 query 向量,一次近似最近邻搜索就能从上百万条里捞出候选。速度快,但对细微差别的判断粗糙。交叉编码器把 query 和文档拼在一起过模型,能捕捉词与词之间的对应关系,精度高,但每一对都要实时算,不可能扫全库。
所以标准做法是漏斗:召回几十上百条,重排之后只留几条。重排的输入规模决定了它的成本,重排的对象从一千条降到五十条,延迟差一个量级。这个数字不是拍出来的,要看延迟预算里能留给它多少。
延迟预算应该先分配再实现。端到端给定一个总预算,检索、重排、生成各拿多少,最好在动手前写下来。写完会发现重排能拿的空间很小,也就能理解为什么很多团队只用它处理高风险查询。
查询类型也可以分流。含编号、型号、错误码的查询,先走关键词,再补向量;纯自然语言的开放问题,直接走向量加轻量重排。这个分流用一个判断规则就能做,不需要模型,成本几乎为零,但能省掉不少不必要的重排。
什么时候不该加重排:库很小、查询很直接、延迟预算极紧。这三种情况下,重排的边际收益抵不过它带来的 P99 抖动。重排模型通常要常驻 GPU,对一个小团队来说,这是一笔固定成本,不是按量付费。加之前先问一句,这台机器闲着的时候谁付钱。
混合检索也是同一个逻辑。加一路 BM25 是为了补精确匹配的短板,融合时用 RRF 按排名合并,避免两种分数的尺度问题。两路各取多少条、权重怎么定,我用验证集做网格搜索,看召回而不是看总分。这个过程很土,但比拍脑袋可靠,也方便在换 embedding 模型之后重新校准。
加权求和也不是不能用。它保留了两路分数的强弱信息,在某些查询上比按排名融合更细腻,但需要对分数做归一化,而归一化的参数又会随数据漂移。RRF 牺牲了这部分信息,换来确定性和几乎不用调参。对我来说这笔交换划算,因为我不相信一个每月都要重调的参数。
代价是两套索引、两套调参、两倍的写入链路。还有一个隐性成本:两路召回的结果要是一致性差,重排模型会收到互相矛盾的候选,判断反而更难。
近似搜索的参数也会悄悄影响召回。图索引的搜索范围、倒排索引探测的簇数,调小一点延迟就降,代价是高分文档被漏掉,而且漏得很随机。这类问题在离线评测里不明显,线上却表现为“偶尔答非所问”。我的习惯是把参数写进配置,和评测结果一起记录,改动必须附一次跑分。
检索链路每加一层,都是在用维护成本换准确率。加之前先问一句,这一层坏了谁来修。
重排模型的选择比参数更影响体验。小模型快、显存友好,但对“意思相近、结论相反”的文档区分不出来;大模型判断更准,延迟和成本都往上走。我一般先用小的跑评测集,看前几条排序对不对,不够再换。
重排效果要单独评。固定同一批召回结果,只看重排前后的排名变化,才能判断是重排的问题还是召回的问题。混在端到端指标里看,两边的改动会互相掩盖,最后谁也不知道是哪一步起了作用。
上下文是排版问题
长窗口没有解决注意力分配的问题。上下文变长,模型对中间部分的利用反而下降,首尾的信息更容易被采纳。这不是某个模型的缺陷,是普遍现象。
所以我把上下文构建当成排版来做。相关性最高的两段放头尾,次相关的放中间,而不是简单按分数从高到低堆。这个改动不需要训练,不需要换模型,改一个拼接函数就行。
另一个习惯是设阈值而不是固定取前 k 条。分数普遍偏低的时候,宁可供三条,也不要凑满十条。噪声进去以后,模型不会说明它被干扰了,它只会答得更含糊。阈值要按查询类型分别定,事实查询可以严一点,开放讨论可以松一点。
多轮对话里,检索用的 query 不能直接用用户最后那句话。“那这个呢”“第二个方案呢”这类追问,脱离上文没有意义。我会用最近两三轮对话让模型重写一个自包含的查询,再拿去检索。这一步的成本很低,但它对连续追问的体验影响很大,也是最容易被跳过的一步。
上下文里还要标明片段之间的关系。同一个主题下不同版本的两段话,如果只按顺序拼进去,模型无法判断哪个更新。我习惯在每段前面加一行来源信息:文档名、版本、章节、更新时间。占用的 Token 不多,但能显著减少“引用了旧版本”的错误。
“两端布局”在不同模型上的收益不一样,有的模型对顺序不敏感,有的非常敏感。我不会针对单个模型去调布局,因为换模型就得重调,维护成本太高。固定一种“强信息在两端、弱信息在中间”的写法,够用就行。
上下文压缩我用的不多。让模型先提炼再回答,等于多一次推理,还会引入新的失真。只有在上下文确实超出预算、又必须保留全部来源时才值得做。真要做,我会保留原文的引用编号,方便回溯。
能塞进去,不等于模型会读。
这句话我在评审里说过很多次。窗口大小是容量指标,不是效果指标。把窗口当卖点的方案,通常没有认真做过上下文排序。
截断策略也要有。真的超预算时,先丢低分片段,再丢重复内容,最后才压缩。顺序反了,会把唯一有用的那段压成一句概括。
防线和评测都是苦活
第一层是 prompt 约束:只依据给定材料回答,材料里没有就说不知道。它有效,但不可靠。模型对“没有”的判断很弱,尤其在材料里存在相关但不完整的内容时,它倾向于补全。
第二层是引用。要求每句结论标注来源编号,然后在代码里检查编号是否真的存在于本次召回列表。这一步把生成问题变成了校验问题,能拦住一部分凭空引用。拦不住的是“引用正确但结论过度”的情况。要做细一点,可以进一步核对引用段落里是否真的包含答案中的关键实体,不过这需要额外的模型判断,成本和误报都会上升。
第三层是事后校验。用一个小模型或自然语言推理模型判断答案是否被材料支持,或者对同一问题采样几次看结论是否一致。这两种方法都贵,只适合高风险场景。自洽投票还有一个副作用:多次采样会放大模型本身的偏好,如果它每次都错在同一个地方,投票只会让错误更自信。
三层都上,成本和延迟会很可观。多数团队停在第一层,这是理性的选择,前提是接受它的漏检率,并且在产品上给用户留出核对原文的入口。
拒答也可以分级。检索置信度高、答案有明确引用,就直接答;置信度中等,给出带条件的回答并附上原文;置信度低,转成搜索或者转人工。硬性拒答看着安全,但用户的问题并没有被解决,只是被推回去了。分级处理麻烦,体验上更接近真实的能力边界。
评测集我控制在五十条左右,全部来自真实问题。编出来的问题句式工整、术语准确,接近考试题;真实用户的问法省略主语、夹杂口语、指向不明。这两种分布差得很远,用前者调出来的系统,上线后会被后者打穿。
每条问题标三样东西:标准答案、必须出现的关键条件、必须引用的文档。评分时不看中间分数,只看最终回答有没有满足这些条件。这样结果不依赖某个框架的实现细节,换工具也不用重做。
检索层和生成层要分开评。召回率可以通过放宽阈值刷上去,答案正确率不会跟着动。只看总分,很容易得出“系统在进步”的错觉。
评估要进流水线,而不是攒到版本发布前做。每次改切分、改 prompt、换模型,自动跑一遍,把指标和上一次对比。人不会每次都记得跑,流程会。这件事的维护成本主要在用例,不在脚本。
跑批的成本也要控制。全量评测一晚跑完可以接受,但如果每次提交都跑,费用会很快堆起来。我一般把用例分成两档:一档小而快,进每次提交;一档全量,进每日任务。
还有一个容易被忽略的维度是时效。同一个问题,旧版文档和新版文档的向量相似度可能只差一点点,旧文档因为写得规范,反而更容易被召回。我的处理是在索引阶段强制带上更新时间,检索时先按时间做过滤,或者在排序分上叠一个衰减项。硬过滤更确定,但会把“确实需要历史版本”的查询挡掉,所以只在政策类知识库上用。
这套评估体系最麻烦的地方不是搭,是维护。问题会过期,答案会变,标注的人会换。我暂时没有找到让它自动更新的办法,只能每季度人工过一遍。这件事不产生新功能,但它是唯一能说明系统没有变坏的东西。
评论