混合检索与融合重排 — Dealer 核心
30 秒导读: 这是 RAGFlow「读取线」的高潮。用户问一句话,系统要从千万级 chunk 里挑出最相关的几段喂给大模型。RAGFlow 的做法是双路召回 + 融合重排:一路把问句拆成带权重的关键词布尔查询(BM25 全文检索),一路把问句编码成向量做 cosine 近邻(稠密检索),两路的候选合并后,再按用户设定的权重把「词面相似度」和「向量相似度」加权融合,排序、切页、过阈值,最后把答案切片匹配回原始 chunk 生成可点击的引用编号。全部逻辑收在一个叫
Dealer的类里。
本章聚焦「文本 + 向量的通用混合检索」。结构化召回(RAPTOR 树、GraphRAG 图谱、查询分解)是另一套东西,留给 04-advanced-retrieval-raptor-graphrag.md。写入线怎么把 chunk 分词、算向量、建索引,见 02-ingestion-write-path.md。
1. 这是什么(零基础也能懂)
-
一句话定义: 把用户的自然语言提问,变成「既看关键词、又看语义」的检索请求,召回一批候选 chunk,再重新打分排序,交给 LLM 生成答案。
-
解决什么问题: 纯关键词检索(BM25)召不回同义改写(问「怎么退货」,文档写「退款流程」);纯向量检索(语义)又常常在专有名词、数字、代号上翻车(把「iPhone 15」和「iPhone 14」当成几乎一样)。两者 各有盲区,合起来才稳。 这就是「混合检索(hybrid retrieval)」。
-
给谁用: 上层的对话服务(
api/db/services/dialog_service.py)、Chunk 检索 API、Agent 的 Retrieval 工具,统一调Dealer.retrieval(...)这一个入口。 -
用起来什么样: 一次真实调用长这样(来自
rag/benchmark.py:58):
# settings.retriever 就是 search.Dealer 的单例
ranks = await settings.retriever.retrieval(
query, # 用户问句
embd_mdl, # 嵌入模型(把文本变向量)
tenant_id, [kb_id], # 租户 + 知识库
page=1, page_size=30, # 要第几页、每页几条
similarity_threshold=0.2, # 低于这个分就丢
vector_similarity_weight=0.3, # 向量占 30%,词面占 70%
)
# ranks["chunks"] 是排好序的候选:每条带 similarity / vector_similarity / term_similarity
- 一句话直觉: 把它想成一个二次面试流程。第一轮(召回)靠数据库快速海选,尽量别漏;第二轮(重排)在应用侧用更讲究的公式重新打分,决定最终名次。数据库快而糙,应用侧慢而精。
本节不碰代码细节。记住一件事:Dealer 就是这个「双路海选 + 精细重排」的执行者。
2. 顶层全景(它大概怎么转)
一次 retrieval() 从提问到结果,数据流是这样(从左到右,命中即往下):
用户问句 "怎么给知识库配置重排模型?"
│
▼
┌───────────────────────────────────────────────┐
│ ① 查询构造 FulltextQueryer.question │ rag/nlp/query.py
│ 中英分流 → 去停用词 → 切词/term_weight 加权 │
│ → 同义词扩展 → 拼成带 ^权重 的布尔串 │
│ 产出: MatchTextExpr(BM25) + keywords 列表 │
└───────────────────────────────────────────────┘
│ │
│ ② 稠密召回 │ ① 文本召回
▼ get_vector ▼
MatchDenseExpr (同一个 MatchTextExpr)
(q_<dim>_vec, cosine) │
└───────────┬─────────────┘
▼
┌───────────────────────────────────────────────┐
│ ③ 双路融合入口 Dealer.search │ rag/nlp/search.py
│ [matchText, matchDense, FusionExpr] 一次下发 │
│ 数据库(ES/Infinity/OceanBase)海选出候选窗口 │
│ → SearchResult(ids, field, query_vector...) │
└───────────────────────────────────────────────┘
▼
┌───────────────────────────────────────────────┐
│ ④ 重排(三选一,按后端 + 是否外置模型) │ rag/nlp/search.py
│ a. 外置模型 rerank_by_model │
│ b. ES 路径 _knn_scores + rerank_with_knn │
│ c. Infinity 直接用 _score / OceanBase 走 rerank│
│ 都叠加 rank_feature(PAGERANK 加权) │
└───────────────────────────────────────────────┘
▼
┌───────────────────────────────────────────────┐
│ ⑤ 排序整形 稳定排序→阈值过滤→切页→doc_aggs聚合 │ rag/nlp/search.py retrieval()
└───────────────────────────────────────────────┘
▼
ranks["chunks"] → 喂给 LLM 生成答案
▼
┌───────────────────────────────────────────────┐
│ ⑥ 可溯源引用 insert_citations │ rag/nlp/search.py
│ 把答案切句,按 hybrid_similarity 匹配回 chunk │
│ 在句尾插 [ID:3] 这样的引用标记 │
└───────────────────────────────────────────────┘
各部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
FulltextQueryer.question | 问句 → 带权重的 BM25 布尔查询 + 关键词 | rag/nlp/query.py:42 |
term_weight.Dealer | 给每个词算 IDF×词性×命名实体权重 | rag/nlp/term_weight.py:164 |
synonym.Dealer.lookup | 同义词扩展(自定义词典 + WordNet) | rag/nlp/synonym.py:80 |
Dealer.get_vector | 问句 → 稠密向量近邻表达式 | rag/nlp/search.py:53 |
Dealer.search | 下发双路召回,组装 SearchResult | rag/nlp/search.py:132 |
Dealer.retrieval | 顶层编排:分页、重排、阈值、聚合 | rag/nlp/search.py:573 |
rerank_* / _knn_scores | 三条重排路径 | rag/nlp/search.py:443-540 |
insert_citations | 答案切片回匹 chunk,插引用 | rag/nlp/search.py:242 |
主线走一遍(不进代码):问句进来 → 拆成关键词布尔查询 + 一条向量 → 数据库双路海选出一个候选窗口 → 应用侧按用户权重重新打分 → 排序切页过阈值 → 返回带三种相似度的 chunk 列表。下面逐段拆开。
3. 核心机制(逐个拆解)
3.1 查询构造 — 把一句话拆成带权重的布尔查询
要解决的小问题: BM25 检索不认识「整句话」,它认「词 + 每个词多重要」。所以第一步是把 "怎么给知识库配置重排模型?" 这种口语,变成数据库能吃的 (重排^2.1 (rerank)^0.5) OR (模型^1.3 ...) OR ("重排 模型"~2)^1.5 这种带权重、带同义词、带短语的布尔串。
入口: FulltextQueryer.question(txt, min_match=0.6),rag/nlp/query.py:42。注意 search() 实际传的是 min_match=0.3(search.py:173),比默认宽松。
处理流水线(顺序执行):
原始问句
│ add_space_between_eng_zh 中英之间补空格 "iPhone15手机"→"iPhone15 手机"
│ strQ2B + tradi2simp 全角转半角、繁体转简体
│ 正则清洗 干掉 Infinity 词法器会误判的转义字符 :^"'~*?( 等
│ rmWWW 去疑问词/停用词 "怎么/如何/what/is/the..."
▼
is_chinese? ──否──▶ 英文路径(query.py:59-95)
│是
▼
中文路径(query.py:104-181)
关键设计一:每个词都带权重 ^weight。 权重来自 term_weight.Dealer.weights(term_weight.py:164),公式是 (0.3·IDF_freq + 0.7·IDF_df) × ner(t) × postag(t)——即「逆文档频率 × 命名实体类型 × 词性」。人名/地名/机构名(ns/nt、corp/loca)权重放大到 3,虚词(代词/连词 r/c/d)压到 0.3。所以专有名词天然比「的、了」重要。
关键设计二:同义词扩展,但权重打折。 每个词查 syn.lookup(query.py:71、137),命中的同义词以原权重的 0.2~0.25 倍挂上去(英文路径 w/4.,query.py:73;中文路径 (...)^0.2,query.py:151)。既扩了召回,又不让同义词盖过原词。
关键设计三:相邻词组成短语,权重翻倍。 相邻两个词会拼成 "重排 模型"~2 这样的邻近短语(~2 允许间隔 2 个词),权重取两词较大值 ×2(英文 query.py:78-89)或 ×1.5(中文整句 query.py:160)。命中连续短语的 chunk 应该排更前,这是词序信息。
教学示例(演示中文路径的产物,# 示意,非源码):
# 输入: "重排模型怎么配置" → rmWWW 去掉"怎么" → 剩 "重排模型 配置"
# 每个切词块 tt 产出一个子查询,最后用 OR 连起来:
q = [
"((重排^2.1 OR (rerank)^0.2) OR \"重排\"~2)^5 OR (\"重新排序\")^0.7", # 词 + 细粒度 + 同义词
"(配置^1.3 ...)^5 OR (\"设置\")^0.7",
]
query = " OR ".join(f"({t})" for t in q) # 各子查询 OR 连接
# 打包成 MatchTextExpr,附带 minimum_should_match 门槛
真实产物: 函数返回 MatchTextExpr(query_fields, query, 100, {"minimum_should_match": min_match, ...}) 和 keywords 列表(query.py:178-180)。其中 query_fields 是带字段权重的检索目标(query.py:32-40):
| 字段 | 权重 | 含义 |
|---|---|---|
important_kwd^30 | 30 | 人工/抽取的关键词,最高 |
question_tks^20 | 20 | chunk 关联的问题 |
title_tks^10 | 10 | 标题 |
content_ltks^2 | 2 | 正文分词 |
content_sm_ltks | 1 | 正文细粒度分词 |
minimum_should_match(最少命中比例) 是召回宽严的总闸门:0.3 意味着布尔串里的 should 子句至少要命中 30%。ES 侧把浮点翻译成百分比字符串 "30%"(rag/utils/es_conn.py:204-206)。
英文 vs 中文两条路的差异:
| 维度 | 英文路径(query.py:59-95) | 中文路径(query.py:104-181) |
|---|---|---|
| 判定 | is_chinese 为假(非中文词占比低) | 为真 |
| 切词 | rag_tokenizer.tokenize 直接切 | tw.split 先按 NER 合并再切 |
| 细粒度 | 无 | fine_grained_tokenize 拆子词(query.py:117-119) |
| 短语 | 相邻词 bigram "a b"^w(:78) | 整句 "..."~2^1.5(:160) |
minimum_should_match | 不设(靠字段权重) | 设 min_match(:179) |
坑:两条路都对候选词做了
[:256]截断(query.py:68、106),关键词列表另有< 32的上限(query.py:112、133等)。超长问句的尾部词会被丢弃,这是刻意的性能护栏。
3.2 稠密召回 — 问句变一条向量
要解决的小问题: 语义匹配。BM25 那套只认字面;这一路把整句问话编码成一个高维向量,去和每个 chunk 的向量算 cosine,找语义最近的。
真实实现: Dealer.get_vector,rag/nlp/search.py:53-61:
qv, _ = await thread_pool_exec(emb_mdl.encode_queries, txt) # 问句 → 向量
embedding_data = [get_float(v) for v in qv]
vector_column_name = f"q_{len(embedding_data)}_vec" # 如 q_1024_vec
return MatchDenseExpr(vector_column_name, embedding_data,
'float', 'cosine', topk, {"similarity": similarity})
三个要点:
- 列名带维度:
q_1024_vec——不同嵌入模型维度不同,列名把维度写进去,一个索引里能共存多种维度。写入线用同样规则建列(见 02 章)。 - 距离度量
cosine: 余弦相似度,范围规约到[0,1]。 similarity是召回门槛: 低于它的直接不返回。默认0.1(search.py:181),比最终similarity_threshold(默认 0.2)宽松——海选阶段先放进来,精排再筛。
3.3 双路融合入口 — Dealer.search 与候选窗口分页
要解决的小问题: 把 3.1 的文本表达式和 3.2 的向量表达式一次性下发给数据库,让数据库返回一个融合后的候选窗口。同时解决一个隐蔽的工程问题:深翻页怎么不丢结果。
双路一次下发: search.py:192-193:
fusionExpr = FusionExpr("weighted_sum", topk, {"weights": "0.05,0.95"})
matchExprs = [matchText, matchDense, fusionExpr] # 文本 + 向量 + 融合规则
res = await thread_pool_exec(self.dataStore.search, ...)
这里的 weights="0.05,0.95" 是第一轮海选时数据库内部融合文本分和向量分的权重(向量占 95%),不是最终展示的相似度。最终相似度由第 3.4 节的重排用用户的 vector_similarity_weight 重新算。别混淆这两个权重。
空结果降级重试: 若第一轮 total==0,把 min_match 从 0.3 放宽到 0.1、similarity 从 0.1 放宽到 0.17 再来一次(search.py:200-212)。宁可放宽也别返回空。
候选窗口 + 分块分页(最烧脑的一段): 用户要「第 3 页,每页 10 条」,但重排必须在一个足够大的候选池里做才准。RAGFlow 的解法是引入一个 RERANK_LIMIT 窗口(约 64,_rerank_window,search.py:548-571):
RERANK_LIMIT (窗口, ~64, 必须是 page_size 整数倍)
├──────────────────────────────────────┤
全局偏移 global_offset = (page-1) * page_size
│
├─ 取哪个块: req["page"] = global_offset // RERANK_LIMIT + 1
└─ 块内起点: begin = global_offset % RERANK_LIMIT
page_idx = valid_idx[begin : begin + page_size]
为什么窗口必须是 page_size 的整数倍: 因为「取第几块」用整除、「块内切页」用取余,两者共用同一个 RERANK_LIMIT。若不是整数倍,块边界和页边界会错位,深翻页会静默丢结果、返回短页(_rerank_window 的 docstring search.py:558-565 明确警告)。所以窗口用 math.ceil(64/page_size)*page_size 向上取整到整页(search.py:568)。当启用外置重排模型时,窗口还会被 top 上限夹住(search.py:570)。
三个关键旋钮:
| 参数 | 默认 | 作用 | 位置 |
|---|---|---|---|
similarity_threshold | 0.2 | 最终分数低于它就丢 | retrieval() 形参 search.py:581 |
vector_similarity_weight | 0.3 | 向量分占比,词面占 1-它 | search.py:582 / 629 |
top / topk | 1024 | 稠密召回的候选上限 | search.py:584 / 145 |
SearchResult 是这一步的产物(search.py:42-51),关键字段:ids(候选 chunk id)、field(每个 chunk 的字段字典,含 _score)、query_vector(问句向量,后续 KNN 二次打分要用)、keywords。
3.4 三条重排路径 — 融合的核心分野
要解决的小问题: 数据库海选给出的分数不够可信(各后端算法不一、量纲不一)。应用侧要用统一公式重新打分:最终分 = 词面权重·term相似度 + 向量权重·向量相似度 + rank_feature。
但「向量相似度从哪来」因后端而异,于是分出三条路(retrieval() 里的分支,search.py:641-680):
是否配置了外置 rerank 模型?
│是 │否
▼ ▼
a. rerank_by_model 按 DOC_ENGINE 分:
模型直接给 query-doc 分 ├─ Infinity → 直接用 _score(库内已归一化融合)
├─ OceanBase → rerank(结果里带向量,本地算)
└─ ES/其它 → _knn_scores + rerank_with_knn
三条路对照:
| 路径 | 向量相似度怎么来 | 词面相似度怎么来 | 代码 |
|---|---|---|---|
| a. 外置模型 | rerank_mdl.similarity(query, docs),已归一化到 [0,1] | token_similarity | rerank_by_model search.py:513 |
| b. ES 路径 | 二次 KNN-only 调用取纯 cosine(_knn_scores) | token_similarity | rerank_with_knn search.py:443 |
| c. Infinity | 不重排,直接用库返回的 _score | 同上(tsim=vsim=sim) | search.py:651-654 |
| c'. OceanBase | 结果里带 chunk 向量,本地 hybrid_similarity 算 cosine | token_similarity | rerank search.py:474 |
ES 路径 为什么要「二次 KNN」(最巧的一处): 第一轮 search 里,ES 把文本分和向量分混在一起给了个 _score,拿不到纯净的 cosine。而且新版为了性能不再把 chunk 向量传回应用(search.py:184-188 注释)。解法:发第二次检索,只做 KNN、只对第一轮的候选 id 打分,让 ES 在库内算好 cosine 再返回纯分数(_knn_scores,search.py:367-400;similarity 设 0.0 保证每个候选都有分)。分数由 get_scores 从 _id → _score 提取(common/doc_store/es_conn_base.py:305)。向量始终不出库,只在需要引用时才按需回取(fetch_chunk_vectors,search.py:402)。
融合公式(ES 路径,rerank_with_knn search.py:467-471):
tksim = token_similarity(keywords, ins_tw) # 词面相似度
vtsim = [knn_scores.get(cid, 0.0) for cid in ids] # 纯 cosine
rank_fea = self._rank_feature_scores(rank_feature, sres) # PageRank + 标签特征
sim = tkweight * tksim + vtweight * vtsim + rank_fea
词面相似度 token_similarity 怎么算(query.py:193-208): 把词序列变成加权字典——每个词记 0.4·权重,相邻词对记 0.6·max(两词权重)(捕捉词组)。然后 similarity 用一个非对称重叠度:查询词里有多少(按权重)在文档里出现,除以查询总权重(query.py:210-222)。注意它只看查询覆盖率,不惩罚文档长度——刻意偏向召回。
重排时给字段加权重(rerank_with_knn search.py:460-465): 拼 token 时 title×2 + important_kwd×5 + question_tks×6,标题和关键词被复制多份,等于在词面相似度里放大它们的贡献。
rank_feature — PageRank 加权(_rank_feature_scores search.py:334-365): 这是叠加在融合分之上的第三项。
- PageRank(
PAGERANK_FLD): 每个 chunk 有个「重要度」分,直接加到最终分上(search.py:339-343、365)。retrieval默认传{PAGERANK_FLD: 10}(search.py:588)。 - 标签特征(
TAG_FLD): 查询的标签向量和 chunk 的标签向量做归一化点积,×10 再加(search.py:357-365)。标签怎么来见tag_query(search.py:849)。 - ES 侧对 PageRank 之外的标签字段用
rank_feature查询直接在库内加权(es_conn.py:226-230)。
Infinity 为什么不重排: Infinity 的 FusionExpr 用 weighted_sum + normalize="atan" 在库内就把两路分数归一化融合好了(rag/utils/infinity_conn.py:220-222),_score 已经可用,应用侧再算一遍是浪费。
3.5 排序整形 — 稳定排序、阈值、切页、聚合
拿到融合分 sim 后,retrieval() 收尾(search.py:682-771):
- 稳定排序(
search.py:688):np.argsort(sim * -1, kind='stable')——分数相同的候选保持原顺序,结果可复现。 - 阈值过滤(
search.py:691-693): 保留sim >= post_threshold的。特例:当vector_similarity_weight <= 0(纯词面),阈值对词面分无意义,直接置 0 不过滤。 - 切页(
search.py:701-703): 用 3.3 的begin = global_offset % RERANK_LIMIT,从过阈值后的有序列表里切出这一页page_size条。 - 结果整形(
search.py:721-745): 每条 chunk 拼成字典,三种相似度都带上:similarity(融合分)、vector_similarity(vsim)、term_similarity(tsim)——分开返回,前端能显示各占多少。 - 文档聚合
doc_aggs(search.py:747-767): 统计每个文档命中了几 个 chunk,按命中数降序——「这次检索主要来自哪几篇文档」。
删除安全网:重排前会先
_prune_deleted_chunks剔掉「DB 里文档已删、但向量库残留」的僵尸 chunk(search.py:76-118、624)。注释明说这是兜底,不是主删除路径。
3.6 可溯源引用 — insert_citations
要解决的小问题: LLM 生成答案后,怎么标出「这句话是根据哪个 chunk 说的」,给用户可点击的 [ID:3] 引用?
思路: 把答案切成句子,每句再和候选 chunk 做一次 hybrid 相似度匹配,够像就在句尾插引用编号。注意:引用匹配是「答案 vs chunk」,不是「问题 vs chunk」——匹配的是模型实际写出来的内容。
真实实现: insert_citations,search.py:242-332。流程:
答案文本
│ 1. 按代码块 ``` 和句末标点切片(含中英/阿拉伯语标点, search.py:262-270)
│ 2. 丢掉长度 < 5 的碎片
▼
每个答案切片 piece
│ embd_mdl.encode(pieces) → 切片向量 ans_v
│ hybrid_similarity(ans_v[i], chunk_v, 切片词, chunk词, tkweight=0.1, vtweight=0.9)
│ ↑ 注意向量权重高达 0.9,引用匹配更信语义
▼
阈值 thr 从 0.63 起,匹配不到就 ×0.8 逐步放宽到 0.3(search.py:300-314)
取相似度 > 0.99·最大值 的 chunk,最多 4 个,插 [ID:c]
两个细节:
- 代码块保护(
search.py:247-259): 先用````三反引号把答案里的代码块整段抠出来,不在代码块中间切句、不插引用,避免破坏代码。 - 动态阈值(
search.py:300): 从严(0.63)到宽(0.3)逐步降,保证「宁可用低置信引用,也别一个引用都没有」。向量权重 0.9 远高于检索时的默认 0.7,因为引用是语义级对齐。
chunk_v(chunk 向量)通过 fetch_chunk_vectors 按需回取(search.py:402),只在这里才把向量拉回应用。