040AI Agent阅读记录
第 040 卷

从零开始 AI Agent 实战(十一):混合召回、查询改写与重排

建立可量化的 RAG 检索链路:查询改写、pgvector 向量检索、PostgreSQL 全文检索、RRF 融合和 LLM-as-reranker。

乌漆嘛黑和 Ahri
第 040 期

混合召回、查询改写与重排

先看纯向量在哪里失手

前两篇把文档和群聊都切成了 chunk 并写进 pgvector。按余弦相似度取 Top-5,看起来已经能用了。拿真实问题测一批,会撞上三类稳定的失败——而且群聊语料让这三类都更严重,因为用户报错时贴的就是错误码和版本号。

第一类,精确编号与版本。 用户问「3.1.4 具体改了什么」。那条结论本身很短,只有两行字,embedding 向量被大量无关维度稀释;而某个长线程里恰好反复出现「版本」「升级」,语义上更「像」这个问题,于是排在前面。向量检索对短文本天生不利。

第二类,错误码和专有标识。 0xC0000142 这种串,模型压根没在训练数据里见过这个 token 组合,向量距离基本是噪声。偏偏它是用户最常直接粘贴、也最能唯一定位问题的东西。

第三类,口语与书面语的错位。 手册写「渲染进程未能初始化」,用户问「点了没反应」。这一类向量确实处理得不错——但它把正确的那条排到了第 7 位。Top-5 就是一道硬边界,第 6 名和第 60 名对用户来说没有区别。

三类失败的共同点是:embedding 相似度和「业务上最该被读到」不是同一件事。前者是模型对语义邻近的估计,后者取决于这份语料怎么写的。

两路召回

向量路补语义,全文路补字面。PostgreSQL 一个库里两样都有,不用额外引入 Elasticsearch:

-- 向量路:同义表达(统一 chunks 表,左连 documents 处理文档权限与状态)
select c.id, 1 - (c.embedding <=> :query_vector) as score
from chunks c
left join documents d on c.source_type = 'document' and d.id = c.source_ref::uuid
where (c.source_type = 'chat' or (d.status = 'ready' and d.visibility = any(:visible)))
order by c.embedding <=> :query_vector limit 20;

-- 全文路:编号、专有名词、短文本
select c.id, ts_rank_cd(c.search_vector, plainto_tsquery('simple', :query)) as score
from chunks c
left join documents d on c.source_type = 'document' and d.id = c.source_ref::uuid
where (c.source_type = 'chat' or (d.status = 'ready' and d.visibility = any(:visible)))
  and c.search_vector @@ plainto_tsquery('simple', :query)
order by score desc limit 20;

两处细节值得说明。

d.status = 'ready' 来自第 9 篇:正在索引和正在删除的文档不能进候选;群聊源由内部权限统一判定,无需关联 documents 表。

plainto_tsquery('simple', ...)simple 而不是 english,因为语料是中文。PostgreSQL 内置的分词器不认中文词边界,simple 配置退化成按空白和标点切——对中文近乎无效。中文全文检索有两条路:装 pg_bigmzhparser 扩展,或者在写入 search_vector 时先用 jieba 分好词再拼成空格分隔的字符串。后者不需要动数据库扩展,部署简单,本项目用它:

def to_search_text(chunk_text: str) -> str:
    return ' '.join(jieba.cut_for_search(chunk_text))

代价是分词质量取决于 jieba 的词典,新业务词汇要手动加进 jieba.load_userdict。这是个明确的取舍:换来的是 docker compose up 里少一个扩展。

RRF:为什么按排名融合

两路的 score 完全不可比。向量路的 1 - 余弦距离 落在 0 到 1 之间,0.82 是个不错的分数;全文路的 ts_rank_cd 没有上界,取值和词频、文档长度都有关,可能是 0.03 也可能是 1.7。给它们加权求和,等于在比较两个不同单位的量。

Reciprocal Rank Fusion 只用排名:

def rrf(result_sets, k=60):
    scores = defaultdict(float)
    rows = {}
    for result_set in result_sets:
        for rank, row in enumerate(result_set, start=1):
            scores[row.id] += 1 / (k + rank)
            rows[row.id] = row
    return sorted(rows.values(), key=lambda row: scores[row.id], reverse=True)

k=60 是这个方法论文里的经验值,作用是压平头部差距。k 很小时,第 1 名和第 2 名的权重差距会非常大,融合结果基本由「谁在某一路排第一」决定;k=60 之后,第 1 名 1/61 和第 3 名 1/63 相差不到 4%,于是两路都排进前列的候选会胜过只在单路夺冠的候选。这正是想要的行为。

Preview
从一个问题到最终五条证据的完整链路

图里两路入口处各有一个权限过滤块。这个位置是刻意的:过滤必须在召回的 SQL 里,不能先召回 20 条再在 Python 里筛掉无权的。理由不是性能——是那些被筛掉的标题在进入 Python 之前,就已经不该出现在这个请求的任何变量里。

查询改写:收益全在补全省略

多轮对话里,用户的第二句话通常没有主语:

用户:点桌面图标没反应
助手:3.1.4 上渲染进程起不来,先结束残留进程再清缓存。
用户:那 3.2 呢

「那 3.2 呢」拿去检索是一句几乎没有信息的话。它只贡献「3.2」这一个 token,而索引里真正的答案谈的是「3.2 之后启动异常的处理方式」。

REWRITE_PROMPT = '''把最后一句改写成不依赖上下文的检索问题。
只输出问题本身,不要回答问题,不要解释。
历史:
{history}
最后一句:{question}'''

async def rewrite(history: str, question: str) -> str:
    try:
        result = await provider.complete_text(
            REWRITE_PROMPT.format(history=history[-1500:], question=question),
            timeout=3, max_tokens=80,
        )
        return result.strip() or question
    except (TimeoutError, ProviderError):
        return question          # 改写失败就用原问题,不能让检索整体不可用

三个约束都不是可选项。timeout=3max_tokens=80:改写是链路上的额外一跳,不能让它成为延迟大头。return question 兜底:改写是增强手段,模型超时的时候降级到原问题仍然能检索,比返回 500 好得多。提示词里明写「不要回答问题」:不加这句,模型经常直接把答案写出来——它见过太多问答数据,而你正好给了它一个问题。

Preview
同一个追问,改写前后的召回结果对照

图里的关键不是 Recall 从 0.31 涨到 0.86 这个数字,而是改写只加了两个词:「启动」和「异常」。这两个词是索引里真实存在的说法。改写的价值在于把对话上下文里的隐含信息显式化,不在于把问题写得更漂亮。

两个查询都要落进 trace:

{'original_query': '那 3.2 呢',
 'rewritten_query': '3.2 版本启动异常、点图标无窗口的处理方式',
 'rewrite_latency_ms': 380}

没有这两个字段,下次报表变好时你分不清是改写起了作用,还是同事顺手换了 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 从秒级变成分钟级,部署门槛整体上一个台阶。

所以先用小模型逐条打分:

RERANK_PROMPT = '''给每个候选片段与问题的相关性打 0-3 分。
3 = 直接回答了问题,2 = 相关但不完整,1 = 同主题,0 = 无关。
只输出 JSON 数组:[{{"id": "...", "score": 0}}]
问题:{query}
候选:
{candidates}'''

scores = await provider.json(RERANK_PROMPT.format(query=q, candidates=render(cands)))
reranked = sorted(cands, key=lambda c: scores.get(c.id, 0), reverse=True)[:5]

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——固定一个基线,每次只动一个因子:

实验对比项固定其余
切分粒度300 / 500 / 800基线其余全部
Top-K3 / 5 / 8同上
检索方式纯向量 / 纯全文 / 混合同上
查询改写关 / 开同上
重排关 / 开同上
上下文压缩关 / 开同上

这样是 14 组。OFAT 的已知局限是它看不到因子间的交互效应——比如「切分变小」和「Top-K 变大」可能有协同作用,单独测两次都不明显。接受这个局限,因为省下的两个数量级更值钱。真正怀疑某两个因子有交互时,再单独做一次 2×3 的小交叉。

uv run python -m ai_agent_guide.retrieval_eval \
  --config configs/retrieval/ofat.yaml --out reports/retrieval.json

每组固定语料、问题集、模型版本和随机种子,报告四个数: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 之前,而不是靠提示词嘱咐模型「不要引用受限内容」。

本篇验收标准

  1. 合成语料上的 Recall@5、MRR 有固定基线和一条可复现的命令,随机种子写进配置。
  2. 混合召回在四类问题上都不劣于单路:精确编号、同义表达、口语化、拼写错误。
  3. 每条 trace 保存原始问题、改写问题、两路候选、融合结果、最终 Top-K 和各阶段耗时。
  4. 权限过滤在向量路、全文路和重排前均生效,且有对应的评测用例。
  5. 改写和重排都能降级:模型不可用时退回原问题和融合排名,检索链路整体仍然可用。
  6. OFAT 报告同时给出质量指标和延迟指标,不允许只报 Recall。

检索链路现在能找到证据了。下一篇处理反面情况:证据不足时怎么让 Agent 承认自己答不出,怎么把引用标到原文位置,以及怎么把一次失败的问答平滑地转成工单。