大多数 RAG 演示在笔记本电脑上看起来都很出色。给脚本喂一个 20 页的 PDF,问一个问题,看着它引用正确的段落。但将同样的流水线部署到生产环境中时,浪漫就结束了。法律文件会在句子层面被切成两半。厚重的 API 参考文档会让重要的信号淹没在模板化的噪声中。延迟激增。用户等待、焦虑,然后离开。我们也曾狠狠地撞上过这堵墙。因此,我们将检索层彻底拆解,并将其重构为一个经过精确衡量、可调优的系统。结果是,我们得到了一个召回率达到 95% 的流水线,且不会让用户体验变成“幻灯片式”的缓慢等待。
为什么演示版的 RAG 在生产环境中会失效
从业余项目到早期产品,标准技术栈出奇地一致:固定的 token 分块、现成的 embedding,以及单一的向量搜索调用。这种简单性极具诱惑力,当你的语料库干净、规模小且语法可预测时,它确实有效。但生产环境中的数据并非如此。一个固定的 512 token 分块会毫不留情地切断 SaaS 合同中赔偿条款的中部。突然间,你的检索层正把半个法律义务喂给语言模型,并要求它回答一个关于责任的问题。由于上下文断裂,模型产生了幻觉。
大型技术文档会让问题更加复杂。API 文档充满了函数签名、表格和代码块。固定窗口可能会捕捉到 TypeScript 接口的中间部分,却漏掉了其上方的函数名和下方的用法示例。最终,embedding 向量表示的是语法片段和行内噪声,而不是用户询问的实际功能。垃圾进,幻觉出(Garbage in, hallucination out)。
按结构分块,而非按 Token 数量
我们做的第一个改变是停止将分块视为 token 的集合。分块是语义单元。正确的策略完全取决于你正在索引的内容。
对于法律文件,我们转向了尊重文档层级的递归分块(recursive chunking)。它将章节、子章节和条款视为边界。一个条款保持完整,因为条款是一个意义单元。如果你切断了它,法律逻辑就会流失。
对于 API 文档,结构感知分块(structure-aware chunking)将函数、类和端点视为原子单元。一个分块可能包含函数签名、参数及其 docstring。它不会仅仅因为 token 计数器达到了阈值,就随意溢出到下一个工具函数中。这使得 embedding 能够专注于一个离散的功能。
支持工单则更乱。它们是对话式的、有线程的且非线性的。固定分块会从同一个线程中抓取工程师的状态更新和客户投诉,并假装它们构成了一个连贯的单元。我们转向了语义分块(semantic chunking),在话题或发言者切换时进行切分,而不是在 token 预算耗尽时。
企业内部维基通常是组织中最杂乱的数据。格式不统一,缺少标题,且各章节混杂在一起。对于这些数据,我们使用基于 LLM 的分块。在生成 embedding 之前,一个小模型会预读并识别逻辑边界。虽然前期成本比按字符切分要高,但检索质量带来的回报是立竿见影的。
混合检索:结合多种信号
向量搜索很强大,但它也有盲点。输入一个精确的错误代码,如 ERR_CONNECTION_REFUSED_0x800,相似度搜索可能会返回一个无关模块的故障排除指南,因为 embedding 空间将它们聚类在了一起。精确匹配非常重要,而仅靠向量搜索可能会抹平这些差异。
使用 BM25 的关键词搜索可以完美解决精确匹配问题。但它在处理概念距离时会显得力不从心。如果用户询问“高负载下的性能下降”,BM25 会错过一段描述“流量激增期间吞吐量缓慢”的诊断笔记,因为关键词重叠度不够。
我们不再二选一,而是开始并行运行两者。向量搜索和关键词搜索各自返回其排名列表。我们使用倒数排名融合(Reciprocal Rank Fusion, RRF)将它们合并。RRF 简单且极其高效。它根据每个文档在各自列表中的位置进行评分。在两个系统中都排名靠前的文档会获得巨大的权重提升。即使只有一个引擎看中的文档,仍能在最终的候选集中占有一席之地。
在融合之后,我们会通过 cross-encoder 重排序器对顶尖候选结果进行处理。这并非没有代价,它会增加大约 50 毫秒的计算开销,但同时能将召回率提高 15%。cross-encoder 会将完整的查询与每个候选分块(chunk)结合在一起进行评估,从而产生比 bi-encoder 嵌入更加细致入微的相关性评分。这额外的 50 毫秒非常划算。它能防止你将垃圾上下文窗口发送给 LLM,从而避免因等待一个困惑或产生幻觉的回答而浪费两秒钟。
在搜索之前优化查询
用户写查询的方式并不像搜索工程师那样专业。他们会输入“app broken”(应用坏了),或者粘贴晦涩难懂的日志片段,或者提出模糊、模棱两可的问题。如果你直接将这些原始字符串发送到索引中,你得到的也将是垃圾信息。
我们在查询进入检索引擎之前对其进行转换。
首先是查询扩展(query expansion)。系统会从一个简短的问题中生成多个搜索词。例如,用户问:“如何修复超时?”引擎会将其扩展为涵盖连接超时、读取超时、网关超时以及重试逻辑等内容。仅这一种方法就将我们的召回率从 78% 提升到了 96%。
其次是查询分解(query decomposition)。复杂的问题会被拆解为更小的子问题。像“企业客户超过 90 天的退款政策是什么,它与月度计划有何不同?”这样的查询,会被分解为两次针对性强的搜索,而不是一次臃肿的嵌入查找。每个子问题都会独立命中索引,结果随后在下游被重新拼接。这保持了检索的精准度,避免了当单个嵌入试图同时匹配十几个概念时发生的“稀释”现象。
让贝叶斯搜索来调优你的流水线
如果你还在手动调优分块大小(chunk size)、重叠率(overlap ratios)和检索权重,那么你正在浪费性能。我们不再靠猜。
我们定义了一个搜索空间,其中分块大小、重叠百分比、向量与 BM25 的权重以及重排序器阈值都是变量。然后,我们应用了贝叶斯优化(Bayesian optimization)。贝叶斯搜索不是通过数百种随机配置进行网格搜索,而是构建一个关于“什么有效”的概率模型。它提出一种配置,观察召回率和延迟,更新其认知,然后提出下一个配置。随着时间的推移,它会收敛到一个人类无法通过手动操作达到的平衡点。
它找到了我们从未尝试过的组合:更小的分块配合更高的重叠率;略低的稠密向量搜索权重,并搭配更激进的重排序器阈值。这些非显而易见的权衡(tradeoffs)为我们带来了更高的召回率和更低的延迟。
这并不是一次性的设置任务。我们每月都会重新运行超参数优化。你的语料库会发生漂移,用户行为也会发生变化。你的流水线应该随之适应,而不是原地生锈。
收益
这种重构带来的原始输出是不容置疑的。
前十位的召回率从 78% 提升到了 95%。当正确答案存在于我们的知识库中时,我们每 20 次就能成功呈现 19 次。P95 延迟从 850 毫秒降至 320 毫秒。对话感觉是即时的,而不是迟钝的。
更好的检索为语言模型提供了更好的事实依据(grounding)。幻觉率从 12% 降至 3%。当模型面前有正确的上下文时,它就不再胡编乱造。单次查询成本下降了 38%。更快速、更精准的检索意味着更少的 token 被浪费在无关的上下文、重试循环以及冗长但无用的提示词上。
像构建基础设施一样构建它
如果你正处于从原型向生产环境过渡的阶段,请将检索视为基础设施代码,而非仅仅是一项配置
