RAG

RAG 检索的工程闭环:让表格里的精确数字顶到第一

摘要

RAG 在开放域问答里表现稳定,但一遇到财报、合同、含精确数字的表格,效果就快速下滑。问题往往不在模型,而在检索链路:稠密向量看不到字面信号,混合检索的融合分数又会稀释"唯一直答"的表格块,Rerank 之前的 query 与文档噪声还会干扰精排。

本文按"检索前 / 召回层 / 检索后"三段拆解一条完整的检索链路,给出可复现的代码与一组排序曲线,帮助在工程上把含答案的表格块稳定顶到第一位。

检索不是一次查询

把检索当成"拿问题查一遍向量库",后面一定会踩坑。一次可靠的检索,至少分三层:

第一层是检索前处理。把用户问题打磨好,例如把"营收"扩成"营业收入""营业总收入""主营业务收入"。

第二层是索引与召回。决定用什么方式把候选块捞出来。只靠语义向量,经常看不见表格里的精确数字。

第三层是检索后处理。把候选重新排序,让答案块进入模型真正会读的位置。

三层解决的是不同问题:问法、召回、排序不能混在一起治。

查询改写有用,但边界很硬

查询改写会把一个问题扩成几种说法,再分别检索,最后用 RRF (Reciprocal Rank Fusion) 合并名次。RRF 的好处是只看每条结果在各自列表里排第几,不直接比分数,因此对不同检索器之间的分数分布差异天然鲁棒(原始论文)。

这对财报很有用。"营收"在报表里可能写成"营业收入",也可能写成"营业总收入"。

一种朴素的改写提示词:

function buildExpandPrompt(question: string): string {
  return [
    '把问题改写成说法不同、意图一致的检索式。',
    '覆盖财报常见同义表达,如营业收入、营业总收入、主营业务收入。',
    `问题:${question}`,
  ].join('\n');
}

注释里值得多提一句:提示词刻意只列同义表达,不引入新事实,避免改写后出现"看似合理但与原文无关"的问题。

但查询改写不是万能药。真实验证里,三种问法都拉到 topK=60,那条含有精确营收数字的表格块仍然没有被召回。

这说明瓶颈不在问法,而在稠密向量看不见这张表。复杂 HTML 表格里有很多标签、科目和数字,"营业收入"这几个字被噪声稀释掉了。

精确数字要靠字面信号捞

这时要给检索加第二条腿:稀疏检索。

稠密检索看语义,稀疏检索看字面。财报表格虽然语义很吵,但"营业收入"四个字确实写在里面。PGroonga 是 PostgreSQL 上的全文检索扩展,支持中文分词与高速字面匹配(官网),可以直接挂在原有关系数据库上,降低工程改造成本。

这里有一个关键细节:稀疏检索不能吃整句。把"茅台 2023 年营业收入是多少"整句丢进去,命中数是 0,因为公司、年份、疑问词都会变成硬条件。

清洗成核心词"营业收入"后,命中数变成 36,目标表格块也第一次进了 topK,排在第 8 名。

源码示例里,核心就是把公司和年份剥掉:

export function cleanSparseQuery(question: string, company?: string): string {
  let q = question;
  if (company) {
    // 公司名交给元数据过滤承担,不应作为字面约束进入稀疏查询
    for (const alias of companyAliases(company)) q = q.split(alias).join('');
  }
  // 年份同理,交由元数据时间范围承担,避免污染字面信号
  q = q.replace(/20\d{2}/g, '');
  for (const filler of ['是多少', '多少', '是', '的', '年']) {
    // 移除常见疑问词与虚词,避免成为硬条件把整句锁死
    q = q.split(filler).join('');
  }
  return q.trim();
}

这一步的价值很明确:先让答案块进候选池。没有这一步,后面的精排再强也没东西可排。

混合检索不等于最终答案

很多人会以为,稠密加稀疏,再用 RRF 一融合,就结束了。真实结果不是这样。

目标块在稀疏单路里是第 8 名。混合 RRF 之后,反而掉到第 17 名。

原因很简单:RRF 奖励"多路都靠前"的块。那些审计说明、业务描述,可能语义相关,也多次提到"营业收入"。它们在稠密和稀疏两路都有分。

真正给出数字的表格块,只被稀疏一路看见。它拿不到双路加分,自然会被挤下去。

所以混合检索的定位要摆正:它交付的是召回覆盖,不是最终排序。

召回要广,排序要准,这是两件事。

Rerank 才是最后一脚

答案块进池之后,才轮到 Rerank。

普通召回更像粗筛,Rerank 是精排。Rerank 模型会把 query 和每个候选块拼在一起,让模型逐对判断相关性(Cohere Rerank 文档)。这比单纯看向量距离或词频更慢,但也更准:它能分清"反复提到营业收入"和"真的给出营业收入数字"。

但 Rerank 也不是套一层就灵。同一个候选池里,如果用整句 query 加原始 HTML 表格,目标块还是第 8。

把 query 清洗成"营业收入",再把 HTML 标签压缩后,目标块升到第 1。这里的主因是 query 清洗,文档去 HTML 是增益,但不是核心。

Rerank 前的文档压缩只服务打分器,不污染最终上下文:

export function compressForRerank(text: string): string {
  // 仅去除 HTML 标签,不重写正文:保留字段顺序,避免破坏财报表格的列对齐
  return text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}

这里的设计意图要单独说一下:压缩只发生在打分阶段,给生成模型的原文依然保留完整表格结构。Rerank 模型对噪声敏感,但下游 LLM 反而能读懂 HTML,两边的处理策略不应混用。

最终曲线很清楚:

阶段目标块排名
稠密多查询topK=60 仍未召回
稀疏检索第 8 名
混合 RRF第 17 名
Rerank 处理版第 1 名

这才是 RAG 检索的完整闭环:先把答案块捞进来,再把它顶到第一。

落地口径

  1. 查询改写只做加法。原问题必须保留,改写失败也要退回单 query,避免引入"看似合理但与原文无关"的问题。
  2. 稀疏检索前先清洗核心词。公司、年份这类条件,交给元数据过滤承担。
  3. 混合检索先看候选池覆盖,不要只盯最终 topK 排名。RRF 合并解决的是"召回广度"问题,不是"答案质量"问题。
  4. Rerank 前要处理输入。query 侧清洗核心词,文档侧压缩噪声;压缩只服务打分器。
  5. 给生成模型的内容保留原始表格结构。下游 LLM 通常能读懂 HTML,不应在 Rerank 阶段同时承担上下文工程。

RAG 的检索问题,很多时候不是"模型不够聪明",而是链路里每一层职责没有拆清楚。

检索前扩宽问法,召回层补足信号,检索后重新排序。这三件事分开做,表格里的精确数字才有机会稳稳站到第一名。

延伸阅读

最后

如果你也在做 RAG / Agent 工程,欢迎在评论区或 Issue 里聊聊你的检索链路里最卡的那一段。