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 检索的完整闭环:先把答案块捞进来,再把它顶到第一。
落地口径
- 查询改写只做加法。原问题必须保留,改写失败也要退回单 query,避免引入"看似合理但与原文无关"的问题。
- 稀疏检索前先清洗核心词。公司、年份这类条件,交给元数据过滤承担。
- 混合检索先看候选池覆盖,不要只盯最终 topK 排名。RRF 合并解决的是"召回广度"问题,不是"答案质量"问题。
- Rerank 前要处理输入。query 侧清洗核心词,文档侧压缩噪声;压缩只服务打分器。
- 给生成模型的内容保留原始表格结构。下游 LLM 通常能读懂 HTML,不应在 Rerank 阶段同时承担上下文工程。
RAG 的检索问题,很多时候不是"模型不够聪明",而是链路里每一层职责没有拆清楚。
检索前扩宽问法,召回层补足信号,检索后重新排序。这三件事分开做,表格里的精确数字才有机会稳稳站到第一名。
延伸阅读
- PGroonga 文档:PostgreSQL 上的全文检索扩展,中文分词与高速字面匹配。
- Reciprocal Rank Fusion (Cormack et al., 2009):RRF 合并的原始论文。
- Cohere Rerank 文档:工业级 Rerank 模型接口与最佳实践。
- LangChain Ensemble Retriever:常见混合检索的工程化实现参考。
最后
如果你也在做 RAG / Agent 工程,欢迎在评论区或 Issue 里聊聊你的检索链路里最卡的那一段。