RAG 效果差,九成不是大模型的锅,是检索没捞对料。模型只负责说好话,检索决定它能看到什么。

这篇拆我自己项目(Nuxt3 企业知识库,Nitro + TiDB + Chroma)里跑着的混合检索:BM25 怎么打分、RRF 的 k 为什么是 60、有向量召回了为什么还要精排。代码全部真实在跑,参数直接抄。

为什么要两路

向量检索单干有毛病:换个说法问很准,但专有名词、条款号、缩写型号就拉胯——搜型号给你一堆「看着很像」的,偏偏不是原文。关键词检索反过来:字面命中稳,近义表达没辙。

所以两路都要:向量路(Chroma bge-m3,强在近义表达)+ 关键词路(手写 BM25,强在专有名词/术语),各取所长。

第 1 步:并行粗召

两路并行,各捞 20 条(COARSE_TOP_K = 20),用 Promise.allSettled——一路挂了另一路照常出,向量服务抖动不拖垮整个问答。向量路带 status=published 过滤,关键词路召回后回查 TiDB document_chunks 补全内容(listChunksByIds + Map 保序)。

第 2 步:BM25 这一路的两个旋钮

BM25 公式(对查询 q 中每个词项 qi 累加对文档 d 的得分):

score(q, d) = Σ IDF(qi) · tf(qi,d)·(k1+1) / (tf(qi,d) + k1·(1 - b + b·|d|/avgdl))
IDF(qi)     = ln((N - df(qi) + 0.5) / (df(qi) + 0.5) + 1)
  • k1=1.5 管词频饱和:一个词在文档里出现 10 次,不代表比出现 1 次相关 10 倍,得分要收敛
  • b=0.75 管长文惩罚:长文档天然词多,不打折就被大段废话霸榜

我的真实打分代码(server/utils/bm25.ts,零依赖手写,倒排索引 token → (chunkId, tf)):

const df = posting.size
// IDF:用 +1 平滑避免负值;罕见词区分度高、IDF 大
const idf = Math.log((N - df + 0.5) / (df + 0.5) + 1)
// 词频饱和项:tf 越大得分越高,但受 k1 限制避免高频词刷分
// 文档长度归一化:长文档天然 tf 高,用 b 参数抑制
const norm = 1 - this.b + this.b * (doc.length / (this.avgdl || 1))
const tfComponent = (tf * (this.k1 + 1)) / (tf + this.k1 * norm)
score += idf * tfComponent

为什么手写不上 ES:我这量级(全库 172 个 chunk)引 Elasticsearch 纯属找罪受。启动插件从 TiDB 全量捞 published 分块,分词建倒排一次预热,零冷启动;失败也不阻塞启动,BM25 降级空结果,向量路仍可用。avgdl 用增量公式维护(增删文档各一行数学),不为一个平均值遍历全库。

第 3 步:RRF 融合,扔掉分数只排名次

余弦分卡在 0~1,BM25 分没有上限(能到 30+),直接加权就是关公战秦琼。RRF(Reciprocal Rank Fusion)只看名次:

score(d) = Σ 1/(k + rank_i(d))    // k=60,某路未命中则不参与累加

直觉算例(k=60):

  • 两路都排第 1:1/61 + 1/61 ≈ 0.0328
  • 只有一路排第 1:1/61 ≈ 0.0164
  • 一路第 1、另一路第 20:1/61 + 1/80 ≈ 0.0289

→ 两路都认的才浮上来。 k 是平滑常数:k 越小头部名次优势越大,k=60 是论文经验值,把第 1 和第 2 名的差距抹平,不让某一路一票定生死;候选池小的话 k 可调低。真实实现(server/utils/rrf.ts):

export function rrf(rankedLists: string[][], k = 60): FusedResult[] {
  const scores = new Map<string, number>()
  const ranksMap = new Map<string, Array<number | null>>()
  rankedLists.forEach((list, listIndex) => {
    list.forEach((id, idx) => {
      const rank = idx + 1 // 名次从 1 开始
      const contribution = 1 / (k + rank)
      scores.set(id, (scores.get(id) ?? 0) + contribution)
      // ranks 记录每路名次,未出现记 null,便于调试观察
      let ranks = ranksMap.get(id)
      if (!ranks) { ranks = new Array(rankedLists.length).fill(null); ranksMap.set(id, ranks) }
      ranks[listIndex] = rank
    })
  })
  // ...按融合分降序输出
}

融合取 top-20(FUSE_TOP_K = 20);一路为空自动退化单路。

第 4 步:精排,最不能省的一步

入库时分块是单独算向量的,跟你的问题从没同框比过——粗召本质是盲选。reranker(cross-encoder,硅基流动 bge-reranker-v2-m3)把「问题+每条候选」拼成一对喂进模型逐条打分,慢,但准。

只对融合后的 20 条精排(成本可控),取前 5 进上下文(FINAL_TOP_K = 5)。rerank 服务不可用就自动降级按 RRF 分排——链路永不整条挂掉。

然后生成:DeepSeek deepseek-chat SSE 流式,先推 sources(来源文档+页码)再推 delta,前端引用卡片比答案先渲染。

调试方法:三模式对照

这是整条链路我最推荐抄的东西——检索模式做成 hybrid / bm25 / vector 三档开关,召回策略是唯一变量,后面统一走精排+生成。同一个问题三档各跑一遍,控制台按模式打印两路名次/得分,谁拖后腿一眼看见。另有 /api/rag/keyword-search、/api/rag/vector-search 单路调试接口和 retrieveOnly(只看检索不生成)。

速查表

项值
流程两路并行各召 20 → RRF 融合取 20 → 精排取 5 → 生成
BM25k1=1.5,b=0.75,IDF=ln((N-df+0.5)/(df+0.5)+1)
RRFscore=Σ1/(60+rank),只看名次不看分
降级单路挂→另一路照常;rerank 挂→按 RRF 分
自测同一问题跑纯向量/纯BM25/混合三遍

RAG 的上限在检索,不在大模型。下一篇讲检索的第一站:Query 改写。

本篇最早于 2026 年 10 月 13 日发布于小红书,此为含完整代码与数据的博客版。