混合召回、查询改写与重排
先看纯向量在哪里失手
前两篇把文档和群聊都切成了 chunk 并写进 pgvector。按余弦相似度取 Top-5,看起来已经能用了。拿真实问题测一批,会撞上三类稳定 的失败——而且群聊语料让这三类都更严重,因为用户报错时贴的就是错误码和版本号。
第一类,精确编号与版本。 用户问「3.1.4 具体改了什么」。那条结论本身很短,只有两行字,embedding 向量被大量无关维度稀释;而某个长线程里恰好反复出现「版本」「升级」,语义上更「像」这个问题,于是排在前面。向量检索对短文本天生不利。
第二类,错误码和专有标识。 0xC0000142 这种串,模型压根没在训练数据里见过这个 token 组合,向量距离基本是噪声。偏偏它是用户最常直接粘贴、也最能唯一定位问题的东西。
第三类,口语与书面语的错位。 手册写「渲染进程未能初始化」,用户问「点了没反应」。这一类向量确实处理得不错——但它把正确的那条排到了第 7 位。Top-5 就是一道硬边界,第 6 名和第 60 名对用户来说没有区别。
三类失败的共同点是:embedding 相似度和「业务上最该被读到」不是同一件事。前者是模型对语义邻近的估计,后者取决于这份语料怎么写的。
两路召回
向量路补语义,全文路补字面。PostgreSQL 一个库里两样都有,不用额外引入 Elasticsearch:
两处细节值得说明。
d.status = 'ready' 来自第 9 篇:正在索引和正在删除的文档不能进候选;群聊源由内部权限统一判定,无需关联 documents 表。
plainto_tsquery('simple', ...) 用 simple 而不是 english,因为语料是中文。PostgreSQL 内置的分词器不认中文词边界,simple 配置退化成按空白和标点切——对中文近乎无效。中文全文检索有两条路:装 pg_bigm 或 zhparser 扩展,或者在写入 search_vector 时先用 jieba 分好词再拼成空格分隔的字符串。后者不需要动数据库扩展,部署简单,本项目用它:
代价是分词质量取决于 jieba 的词典,新业务词汇要手动加进 jieba.load_userdict。这是个明确的取舍:换来的是 docker compose up 里少一个扩展。
RRF:为什么按排名融合
两路的 score 完全不可比。向量路的 1 - 余弦距离 落在 0 到 1 之间,0.82 是个不错的分数;全文路的 ts_rank_cd 没有上界,取值和词频、文档长度都有关,可能是 0.03 也可能是 1.7。给它们加权求和,等于在比较两个不同单位的量。
Reciprocal Rank Fusion 只用排名:
k=60 是这个方法论文里的经验值,作用是压平头部差距。k 很小时,第 1 名和第 2 名的权重差距会非常大,融合结果基本由「谁在某一路排第一」决定;k=60 之后,第 1 名 1/61 和第 3 名 1/63 相差不到 4%,于是两路都排进前列的候选会胜过只在单路夺冠的候选。这正是想要的行为。
图里两路入口处各有一个权限过滤块。这个位置是刻意的:过滤必须在召回的 SQL 里,不能先召回 20 条再在 Python 里筛掉无权的。理由不是性能——是那些被筛掉的标题在进入 Python 之前,就已经不该出现在这个请求的任何变量里。
查询改写:收益全在补全省略
多轮对话里,用户的第二句话通常没有主语:
「那 3.2 呢」拿去检索是一句几乎没有信息的话。它只贡献「3.2」这一个 token,而索引里真正的答案谈的是「3.2 之后启动异常的处理方式」。
三个约束都不是可选项。timeout=3 和 max_tokens=80:改写是链路上的额外一跳,不能让它成为延迟大头。return question 兜底:改写是增强手段,模型超时的时候降级到原问题仍然能检索,比返回 500 好得多。提示词里明写「不要回答问题」:不加这句,模型经常直接把答案写出来——它见过太多问答数据,而你正好给了它一个问题。
图里的关键不是 Recall 从 0.31 涨到 0.86 这个数字,而是改写只加了两个词:「启动」和「异常」。这两个词是索引里真实存在的说法。改写的价值在于把对话上下文里的隐含信息显式化,不在于把问题写得更漂亮。
两个查询都要落进 trace:
没有这两个字段,下次报表变好时你分不清是改写起了作用,还是同事顺手换了 embedding 模型。
LLM-as-reranker:为什么不上 cross-encoder
融合后的 Top-20 里,正确答案通常已经进来了,但不一定在前 5。重排就是拿一个更贵的模型重新给这 20 条排序。
标准做法是 cross-encoder(比如 bge-reranker-base):把问题和候选拼在一起过一遍模型,精度明显高于双塔 embedding。本项目不用它,原因很具体:整套技术栈只依赖 OpenAI-compatible HTTP API,而这个协议没有标准的 rerank 端点。引入 cross-encoder 意味着把 torch 和几百 MB 模型权重装进镜像,docker compose up 从秒级变成分钟级,部署门槛整体上一个台阶。
所以先用小模型逐条打分:
scores.get(c.id, 0) 的默认值有讲究。模型漏掉某个 id 是常见情况,给 0 分意味着「漏掉就沉底」。另一种选择是给它原本的融合排名,行为更保守。两者都合理,但要显式选一个,而不是让 KeyError 在生产环境里冒出来。
必须限制候选数量。20 条 chunk 每条 500 字符,这一次调用的输入就有一万字符左右——重排是整条链路上延迟和成本的双重热点。等到流量增长、这个成本变得显著时,再把它换成本地 cross-encoder,接口不变。
实验:OFAT,不做 216 组全交叉
这一篇有六个可调项:切分粒度(300/500/800)、Top-K(3/5/8)、检索方式(纯向量/纯全文/混合)、改写(开/关)、重排(开/关)、上下文压缩(开/关)。全交叉是 216 组,每组跑 50 条用例,一次实验就是上万次模型调用。不做。
改用 OFAT——固定一个基线,每次只动一个因子:
这样是 14 组。OFAT 的已知局限是它看不到因子间的交互效应——比如「切分变小」和「Top-K 变大」可能有协同作用,单独测两次都不明显。接受这个局限,因为省下的两个数量级更值钱。真正怀疑某两个因子有交互时,再单独做一次 2×3 的小交叉。
每组固定语料、问题集、模型版本和随机种子,报告四个数:Recall@5、MRR、p95 延迟、输入输出 Token。这套报表格式复用第 4 篇的评测基线,只是断言换成了检索指标。
不要只看 Recall。 开启重排后 Recall@5 从 0.71 涨到 0.86 是真实收益,但 p95 延迟从 900ms 涨到 2.4 秒同样真实。把这个方案直接上线,等于用用户体验换一张漂亮报表。这两个数必须并排出现在报告里,让取舍显式发生。
上下文压缩是六个因子里最容易被高估的一个。它的做法是把召回的 chunk 先用小模型摘要再送进主模型,省 Token。实测通常是省下 30% 输入 Token,代价是多一次模型调用、增加 400ms 延迟,而且摘要有丢关键数字的风险——「上限 500 元」被摘成「有金额限制」,答案就毁了。在 Token 成本还不是瓶颈时,这个因子的结论多半是「关」。
权限过滤仍然在最前面
第 6 篇的可见性条件要出现在这一篇的三个地方:向量路 SQL、全文路 SQL、以及重排之前。前两个已经在上面的查询里;第三个容易漏——如果重排的候选来自一个没过滤的缓存,受限文档的标题就会随提示词进模型。
模型的上下文没有权限边界。一旦一段文字进了 prompt,它就可能出现在回答里、出现在日志里、出现在下一轮对话里。所以边界要划在它进入 prompt 之前,而不是靠提示词嘱咐模型「不要引用受限内容」。
本篇验收标准
- 合成语料上的 Recall@5、MRR 有固定基线和一条可复现的命令,随机种子写进配置。
- 混合召回在四类问题上都不劣于单路:精确编号、同义表达、口语化、拼写错误。
- 每条 trace 保存原始问题、改写问题、两路候选、融合结果、最终 Top-K 和各阶段耗时。
- 权限过滤在向量路、全文路和重排前均生效,且有对应的评测用例。
- 改写和重排都能降级:模型不可用时退回原问题和融合排名,检索链路整体仍然可用。
- OFAT 报告同时给出质量指标和延迟指标,不允许只报 Recall。
检索链路现在能找到证据了。下一篇处理反面情况:证据不足时怎么让 Agent 承认自己答不出,怎么把引用标到原文位置,以及怎么把一次失败的问答平滑地转成工单。
