RAG-SEARCH · 表格召回融合机制

FRTR 多路召回
与融合图谱

表格(spreadsheet)走独立的 FRTR collection。它是否参与召回由灰度门控决定;参与之后如何与主库融合由召回模式决定。下面拨动开关,看四路数据(主库/表格 × dense/sparse)如何流动、在哪里融合、为何这样设计。

灰度门控 · frtr_gray
召回模式 · recall_mode
召回路由 · routes
dense · 向量/语义 sparse · BM25/关键词 dense 分数归并 · cosine 全局序 RRF 融合 rerank·阈值·topK 输出列表 · 点击任意节点查看作用与原因 →

01 当前链路解读

随开关与所选节点实时更新
提示:点击上方流程图里的节点,这里会展开它的职责与设计原因。

02 dense × sparse 怎么融合?

RRF · Reciprocal Rank Fusion
score(d) = Σ 1k + rank   ( k = 60,业界惯例 )

同一个 chunk 在两路里的名次各贡献一个倒数;拖动它的排名:

#2
#7

名次 = 0 表示该路未召回此 chunk(不贡献分)。

路 A 贡献
路 B 贡献
RRF 原始分(归一化前)

三个核心设计决策

dense×sparse 为什么用 RRF,而不是分数加权相加?

dense 是 cosine ∈[0,1],sparse 是 BM25 无上界,两者量纲不可比 —— 直接加权会被 BM25 的大数值压垮。

RRF 只看排名不看分值,天然免疫量纲差异;融合后再做 min-max 归一化到 [0,1] 输出。

代价:RRF 分跨 query 不可比,所以 score_filter 阈值在纯 RRF 语义下会被跳过。

那 dense×dense 跨库,为什么不能用 RRF?

主库与表格 dense 共用同一 embedding 模型 + COSINE 度量,DenseScore 是跨库可比的绝对相关度 —— 这正是 RRF 不适用的场景。

RRF 丢分只留名次,会把弱表格的头名(cosine 0.4)抬到主库强结果(cosine 0.94)同列,稀释精度。故改用分数归并(FuseDenseByScore):按真实 cosine 全局降序、去重取高分。

可调 frtr_dense_score_weight 微调表格路权重(默认 1.0,只影响排序、不动阈值)。

MERGED 与 SEPARATE 怎么取舍?

MERGED: 表格 chunk 与文档 chunk 进同一候选池竞争排序,输出单一 chunk_list。适合"只要一个最优列表"。

SEPARATE: 表格走独立侧路,产出独立 spreadsheet_chunk_list。表格 chunk 是"文档语义代表"而非直接证据,分离后下游 Agent 可特殊处理(取整表沙箱分析)。

灰度的另一半:builder 写入侧

上面讲的都是 recall(查询) 侧。但灰度真正的意义,是它同时管着 builder(写入) 侧 —— 两个独立 binary 读同一份 zest 配置、调同一个判定函数,决定"写哪个库"与"查哪个库"。这才是灰度不可绕过的根因。

BUILDER 写入侧 · 离线建库
Kafka → processor chain → Milvus
表格文档入链 · ArticleType = Spreadsheet
shouldUseFRTR(ctx)
= SharedFRTRGray().ShouldRecall(
  [workspaceId], [knowledgeId])
命中 → 新切分FRTRSpreadsheetChunking → 写 km_spreadsheet
未命中 → 老路SpreadsheetChunking → 写 老 collection
读 ▸ ◂ 读
frtr_gray ZEST KEY
唯一真源 · 热更新
判定函数 ShouldRecall()
显式白名单 / allow_all
builder == server 同源
SERVER 查询侧 · 在线召回
handler → recall service → Milvus
召回请求 · scope = workspace / knowledge
ShouldRecallFRTRMulti(...)
= SharedFRTRGray().ShouldRecall(
  workspaceIDs, knowledgeIDs)
命中 → 查表格frtrRepo 注入 → 召回 km_spreadsheet
未命中 → 不查frtrRepo = nil → 仅 主库
INVARIANT 两侧调用同一个 ShouldRecall、读同一个 zest key,所以写进 km_spreadsheet 的文档,查询侧也一定会去 km_spreadsheet 找。若两侧判定不同源,就会出现"builder 写了新库、recall 却只查老库"→ 该文档永远召回不到。这就是为什么 recall_mode 也必须先过灰度,不能绕过(详见原则 ②)。