引言
在 RAG 系统中,召回策略决定了知识检索的精度与效率,是整个问答链路的“入口逻辑”。
RAGFlow 的召回模块并非简单的向量检索,而是集成了 参数解析 → 模型一致性校验 → 查询增强 → 混合召回 → 动态重排序 → 阈值过滤与分页 的完整闭环。
本文将深入解析 RAGFlow 的召回源码,拆解其从用户请求到最终候选文档生成的全过程,揭示它在工程层面如何平衡精准性、稳定性与扩展性。
省流版
核心思路
RAGFlow 的召回逻辑围绕“精准匹配、多模融合、动态优化”展开。
通过将 稀疏检索(文本匹配) 与 稠密检索(向量匹配) 融合,再结合 rerank 模型和特征打分机制,实现更智能的上下文召回。
当无结果时,系统还会自动调整相似度阈值和匹配范围,实现“宽搜兜底”。
设计亮点
- 统一模型一致性检测: 多知识库查询时强制校验 Embedding 一致性,避免语义空间错位。
- 查询增强: 通过 Chat 模型进行关键词扩展,提高多义句和口语化查询的理解力。
- 融合检索策略: 稀疏 + 稠密双通道检索;默认按 5% 文本相似度 + 95% 向量相似度加权。
- 动态重排序机制: 支持指定 rerank 模型或基于 ES / Infinity 的自动归一化重排。
- 空结果自适应回退: 智能降低阈值或切换简单检索模式,确保系统“永不空答”。
- 多维特征融合排序: 将文本相似度、向量距离、PageRank、标签特征融合成最终得分,实现更稳健的排序输出。
手撕版
1. 召回参数处理
1.1 用户 token 鉴权
token = request.headers.get('Authorization').split()[1]
objs = APIToken.query(token=token)
if not objs:
return get_json_result(
data=False, message='Authentication error: API key is invalid!"', code=settings.RetCode.AUTHENTICATION_ERROR)
1.2 请求参数解析
req = request.json
kb_ids = req.get("kb_id", []) # 知识库ID列表
doc_ids = req.get("doc_ids", []) # 指定文档ID过滤
question = req.get("question") # 用户查询问题
page = int(req.get("page", 1)) # 页码,默认第1页
size = int(req.get("page_size", 30)) # 每页大小,默认30条
similarity_threshold = float(req.get("similarity_threshold", 0.2)) # 相似度阈值
vector_similarity_weight = float(req.get("vector_similarity_weight", 0.3)) # 向量权重
top = int(req.get("top_k", 1024)) # 初始召回数量
highlight = bool(req.get("highlight", False)) # 是否高亮匹配内容
1.3 模型一致性判断
在 RAGFlow 中,创建知识库需要配置相应 Embedding model,这里需要检查所有查询的知识库使用相同的 Embedding model,避免不同模型产生的向量空间不一致问题。
kbs = KnowledgebaseService.get_by_ids(kb_ids)
embd_nms = list(set([kb.embd_id for kb in kbs]))
if len(embd_nms) != 1:
return get_json_result(
data=False, message='Knowledge bases use different embedding models or does not exist."',
code=settings.RetCode.AUTHENTICATION_ERROR)
1.4 模型初始化
初始化 Embedding model,rerank model,和 chat model。
embd_mdl = LLMBundle(kbs[0].tenant_id, LLMType.EMBEDDING, llm_name=kbs[0].embd_id)
rerank_mdl = None
if req.get("rerank_id"):
rerank_mdl = LLMBundle(kbs[0].tenant_id, LLMType.RERANK, llm_name=req["rerank_id"])
if req.get("keyword", False):
chat_mdl = LLMBundle(kbs[0].tenant_id, LLMType.CHAT)
1.5 查询增强处理
通过 chat model 针对查询进行语义增强
if req.get("keyword", False):
chat_mdl = LLMBundle(kbs[0].tenant_id, LLMType.CHAT)
question += keyword_extraction(chat_mdl, question)
# prompt
## Role
You are a text analyzer.
## Task
Extract the most important keywords/phrases of a given piece of text content.
## Requirements
- Summarize the text content, and give the top {{ topn }} important keywords/phrases.
- The keywords MUST be in the same language as the given piece of text content.
- The keywords are delimited by ENGLISH COMMA.
- Output keywords ONLY.
---
## Text Content
{{ content }}
2. 执行召回
2.1 构建召回参数
RERANK_LIMIT = math.ceil(64/page_size) * page_size if page_size>1 else 1
req = {"kb_ids": kb_ids, "doc_ids": doc_ids, "page": math.ceil(page_size*page/RERANK_LIMIT), "size": RERANK_LIMIT,
"question": question, "vector": True, "topk": top,
"similarity": similarity_threshold,
"available_int": 1}
2.2 执行召回
sres = self.search(req, [index_name(tid) for tid in tenant_ids],
kb_ids, embd_mdl, highlight, rank_feature=rank_feature)
2.3 召回配置初始化
def search(self, req, idx_names: str | list[str],
kb_ids: list[str],
emb_mdl=None,
highlight: bool | list | None = None,
rank_feature: dict | None = None):
if highlight is None:
highlight = False
filters = self.get_filters(req) # 获取过滤条件
orderBy = OrderByExpr() # 排序表达式
pg = int(req.get("page", 1)) - 1 # 页码(从0开始)
topk = int(req.get("topk", 1024)) # 初始召回数量
ps = int(req.get("size", topk)) # 返回结果大小
offset, limit = pg * ps, ps # 计算偏移量
# 默认返回字段列表
src = req.get("fields",
["docnm_kwd", "content_ltks", "kb_id", "img_id", "title_tks", "important_kwd", "position_int",
"doc_id", "page_num_int", "top_int", "create_timestamp_flt", "knowledge_graph_kwd",
"question_kwd", "question_tks", "doc_type_kwd",
"available_int", "content_with_weight", PAGERANK_FLD, TAG_FLD])
2.4 无查询词的简单搜索
针对没有查询问题的场景,返回对应的 topk chunk。
qst = req.get("question", "")
if not qst:
if req.get("sort"):
orderBy.asc("page_num_int")
orderBy.asc("top_int")
orderBy.desc("create_timestamp_flt")
# 执行无查询条件的搜索
res = self.dataStore.search(src, [], filters, [], orderBy, offset, limit, idx_names, kb_ids)
total = self.dataStore.getTotal(res)
2.5 有查询词的智能搜索
2.5.1 解析查询问题
matchText, keywords = self.qryr.question(qst, min_match=0.3)
def question(self, txt, tbl="qa", min_match: float = 0.6):
...
1)规范查询问题文本格式
在英文和中文文本之间自动添加空格,使文本格式更加规范,提高可读性。
txt = FulltextQueryer.add_space_between_eng_zh(txt)
def add_space_between_eng_zh(txt):
# (ENG/ENG+NUM) + ZH
txt = re.sub(r'([A-Za-z]+[0-9]+)([\u4e00-\u9fa5]+)', r'\1 \2', txt)
# ENG + ZH
txt = re.sub(r'([A-Za-z])([\u4e00-\u9fa5]+)', r'\1 \2', txt)
# ZH + (ENG/ENG+NUM)
txt = re.sub(r'([\u4e00-\u9fa5]+)([A-Za-z]+[0-9]+)', r'\1 \2', txt)
txt = re.sub(r'([\u4e00-\u9fa5]+)([A-Za-z])', r'\1 \2', txt)
return txt
全角转半角、繁体转简体、小写、去除标点。
txt = re.sub(
r"[ :|\r\n\t,,。??/`!!&^%%()\[\]{}<>]+",
" ",
rag_tokenizer.tradi2simp(rag_tokenizer.strQ2B(txt.lower())),
).strip()
移除对查询核心语义影响不大的辅助词汇,保留关键信息。
txt = FulltextQueryer.rmWWW(txt)
def rmWWW(txt):
patts = [
(
r"是*(怎么办|什么样的|哪家|一下|那家|请问|啥样|咋样了|什么时候|何时|何地|何人|是否|是不是|多少|哪里|怎么|哪儿|怎么样|如何|哪些|是啥|啥是|啊|吗|呢|吧|咋|什么|有没有|呀|谁|哪位|哪个)是*",
"",
),
(r"(^| )(what|who|how|which|where|why)('re|'s)? ", " "),
(
r"(^| )('s|'re|is|are|were|was|do|does|did|don't|doesn't|didn't|has|have|be|there|you|me|your|my|mine|just|please|may|i|should|would|wouldn't|will|won't|done|go|for|with|so|the|a|an|by|i'm|it's|he's|she's|they|they're|you're|as|by|on|in|at|up|out|down|of|to|or|and|if) ",
" ")
]
otxt = txt
for r, p in patts:
txt = re.sub(r, p, txt, flags=re.IGNORECASE)
if not txt:
txt = otxt
return txt
2)英文查询处理
分词后进行分词的权重计算。
if not self.isChinese(txt):
txt = FulltextQueryer.rmWWW(txt)
tks = rag_tokenizer.tokenize(txt).split()
keywords = [t for t in tks if t]
# 分词权重计算
tks_w = self.tw.weights(tks, preprocess=False)
tks_w = [(re.sub(r"[ \\\"'^]", "", tk), w) for tk, w in tks_w]
tks_w = [(re.sub(r"^[a-z0-9]$", "", tk), w) for tk, w in tks_w if tk]
tks_w = [(re.sub(r"^[\+-]", "", tk), w) for tk, w in tks_w if tk]
tks_w = [(tk.strip(), w) for tk, w in tks_w if tk.strip()]
self.tw.weights 是计算分词权重的核心方法。这里简单介绍一下其中使用到的权重策略。
ner(t):根据分词是否为数字、短字母或特定类型的命名实体(如公司名、地名、学校名等)分配不同权重。postag(t):根据分词的词性(连词,代词等)分配不同权重,反映不同词性在文本中的重要性差异。freq(t):计算分词的频率特征,并对未识别的分词和长词进行特殊处理。df(t):计算分词在文档集合中的分布情况,反映分词的文档间区分度。idf(s, N):经典的 IDF 计算公式,用于衡量分词的重要性,其中 N 是文档总数,s 是包含该分词的文档数。
权重计算输出示例
[('Chinese', 0.35), ('economy', 0.28), ('development', 0.22), ('quickly', 0.15)]
分词同义词扩展,并赋予 1/4 分词权重。
for tk, w in tks_w[:256]:
# 分词同义词查询
syn = self.syn.lookup(tk)
syn = rag_tokenizer.tokenize(" ".join(syn)).split()
keywords.extend(syn)
syn = ["\"{}\"^{:.4f}".format(s, w / 4.) for s in syn if s.strip()]
syns.append(" ".join(syn))
self.syn.lookup(tk) 是实现分词同义词查找的核心方法。主要通过词表来进行同义词的查询,英文使用 wordnet 词库,中文使用自构建的词库。
结合权重,同义词信息,构建查询表达式
# 1. 单个词 + 同义词查询
q = [
"({}^{:.4f}".format(tk, w) + " {})".format(syn)
for (tk, w), syn in zip(tks_w, syns)
if tk and not re.match(r"[.^+\(\)-]", tk)
]
# 2. 相邻词短语查询(提升相邻词权重)
for i in range(1, len(tks_w)):
left, right = tks_w[i - 1][0].strip(), tks_w[i][0].strip()
if not left or not right:
continue
q.append(
'"%s %s"^%.4f' % (
tks_w[i - 1][0],
tks_w[i][0],
max(tks_w[i - 1][1], tks_w[i][1]) * 2, # 短语权重加倍
)
)
3)构建最终查询参数
return MatchTextExpr(
self.query_fields, query, 100
), keywords
# MatchTextExpr 结构
{
"fields": [
"title_tks^10",
"title_sm_tks^5",
"important_kwd^30",
"important_tks^20",
"question_tks^20",
"content_ltks^2",
"content_sm_ltks",
] # 在这些对应字段进行查询
"query": "" # 查询表达式
"topn": 100 # 返回结果数
"extra_options": "" # 其他配置
}
4)中文查询处理
与英文的处理流程大致相同。
# 前置处理,按照空格对中文进行分词
for tt in self.tw.split(txt)[:256]:
if not tt:
continue
keywords.append(tt)
# 分词权重计算
twts = self.tw.weights([tt])
# 同义词查询
syns = self.syn.lookup(tt)
# 限制同义词查找结果
if syns and len(keywords) < 32:
keywords.extend(syns)
tms = []
for tk, w in sorted(twts, key=lambda x: x[1] * -1):
sm = (
# 精细分词
rag_tokenizer.fine_grained_tokenize(tk).split()
if need_fine_grained_tokenize(tk)
else []
)
...
# 构建最终查询参数
query = " OR ".join([f"({t})" for t in qs if t])
if not query:
query = otxt
return MatchTextExpr(
self.query_fields, query, 100, {"minimum_should_match": min_match}
), keywords
最终查询问题解析输出:
matchText, keywords = self.qryr.question(qst, min_match=0.3)
# 查询信息结构体,同上方 MatchTextExpr 结构
{
"fields": [
"title_tks^10",
"title_sm_tks^5",
"important_kwd^30",
"important_tks^20",
"question_tks^20",
"content_ltks^2",
"content_sm_ltks",
] # 在这些对应字段进行查询
"query": "" # 查询表达式
"topn": 100 # 返回结果数
"extra_options": "" # 其他配置
}
# keywords 关键字列表
keywords = []
2.5.2 稀疏检索
没有设置 Embedding model,通过查询语句和筛选项进行检索。
if emb_mdl is None:
# 查询语句
matchExprs = [matchText]
res = self.dataStore.search(
src, highlightFields, filters, matchExprs, orderBy,
offset, limit, idx_names, kb_ids, rank_feature=rank_feature
)
total = self.dataStore.getTotal(res)
2.5.3 混合检索(稠密+稀疏)
先获取查询文本的向量表示
matchDense = self.get_vector(qst, emb_mdl, topk, req.get("similarity", 0.1))
q_vec = matchDense.embedding_data
创建检索表达式,分词匹配权重 5%,向量匹配权重 95%,进行检索。
fusionExpr = FusionExpr("weighted_sum", topk, {"weights": "0.05,0.95"})
matchExprs = [matchText, matchDense, fusionExpr]
res = self.dataStore.search(src, highlightFields, filters, matchExprs, orderBy, offset, limit,
idx_names, kb_ids, rank_feature=rank_feature)
total = self.dataStore.getTotal(res)
2.5.4 空结果回退策略
当通过上述方案未检索到任何结果,则尝试放宽条件重新搜索
如果过滤条件中有指定文档 id,则进入无查询词的简单搜索返回 limit 切块。
if total == 0:
if filters.get("doc_id"):
res = self.dataStore.search(src, [], filters, [], orderBy, offset, limit, idx_names, kb_ids)
total = self.dataStore.getTotal(res)
调整匹配阈值,分词匹配度 30% -> 10%,向量匹配度 0.1 -> 0.17。
matchText, _ = self.qryr.question(qst, min_match=0.1)
matchDense.extra_options["similarity"] = 0.17
res = self.dataStore.search(
src, highlightFields, filters, [matchText, matchDense, fusionExpr],
orderBy, offset, limit, idx_names, kb_ids, rank_feature=rank_feature
)
total = self.dataStore.getTotal(res)
3. 重排序
如果指定重排序模型,检索分块数大于 0,则使用指定重排序模型对结果进行重排序。
if rerank_mdl and sres.total > 0:
sim, tsim, vsim = self.rerank_by_model(rerank_mdl,
sres, question, 1 - vector_similarity_weight,
vector_similarity_weight,
rank_feature=rank_feature)
如果没有指定重排序模型,则根据搜索引擎进行重排序逻辑
lower_case_doc_engine = os.getenv('DOC_ENGINE', 'elasticsearch')
if lower_case_doc_engine == "elasticsearch":
# ElasticSearch rerank
sim, tsim, vsim = self.rerank(
sres, question, 1 - vector_similarity_weight, vector_similarity_weight,
rank_feature=rank_feature)
else:
# Infinity 在融合前会对每种方式的分数进行标准化,所以这里无需重新排序
sim = [sres.field[id].get("_score", 0.0) for id in sres.ids]
sim = [s if s is not None else 0. for s in sim]
tsim = sim
vsim = sim
3.1 指定模型重排序
sim, tsim, vsim = self.rerank_by_model(rerank_mdl,
sres, question, 1 - vector_similarity_weight,
vector_similarity_weight,
rank_feature=rank_feature)
3.1.1 文本相似度计算
tksim = self.qryr.token_similarity(keywords, ins_tw)
通过 weights 方法计算所有分词的权重,构成查询词权重字典和文档词权重字典列表,weights 方法的实现在英文查询处理有详细介绍。
def token_similarity(self, atks, btkss):
def toDict(tks):
d = defaultdict(int)
wts = self.tw.weights(tks, preprocess=False) # 计算词权重
for token, weight in wts:
d[token] += weight
return d
query_dict = toDict(atks) # 查询词加权字典
doc_dicts = [toDict(tks) for tks in btkss] # 文档词权重字典列表
通过相似度计算公式:相似度 = 查询词的匹配权重/总权重,得到查询词对于各个文档的相似度列表。
return [self.similarity(query_dict, doc_dict) for doc_dict in doc_dicts]
def similarity(self, qtwt, dtwt):
s = 1e-9
for k, v in qtwt.items():
if k in dtwt:
s += v
q = 1e-9
for k, v in qtwt.items():
q += v
return s / q
3.1.2 指定 rerank 模型相似度计算
doc_texts = [remove_redundant_spaces(" ".join(tks)) for tks in ins_tw]
vtsim, _ = rerank_mdl.similarity(query, doc_texts)
3.1.3 排名特征计算
主要是基于标签特征计算文档匹配度,整体实现比较复杂,有兴趣的可以对源码进行研究。这里简单介绍下思路:
- 先对用户查询进行特征计算,计算出 n 个特征标签,以及每个特征标签的权重;
- 针对这 n 个特征标签查询出每个标签对应的文档数量;
- 根据特征标签自身权重和文档数计算特征标签在总文档之中的权重;
- 取权重前 3 作为用户查询特征标签;
- 计算查询特征向量的 L2 范数,提取 PageRank 分数(文档权威性),计算每个文档的标签匹配度;
- 最终分数融合得到排名。
rank_fea = self._rank_feature_scores(rank_feature, sres)
3.1.4 最终混合相似度
文本相似度 + 排名特征,然后与向量相似度加权融合
final_scores = tkweight * (np.array(tksim) + rank_fea) + vtweight * vtsim
3.2 ES 重排序
整体流程和指定模型的重排序流程相似,计算相似度,计算特征,到最终分数排名,在计算相似度中与指定模型不同的是,将指定 rerank 模型向量相似度计算步骤替换成 cos 余弦相似度计算。
sim, tsim, vsim = self.rerank(
sres, question, 1 - vector_similarity_weight, vector_similarity_weight,
rank_feature=rank_feature)
3.3 未指定模型重排序(基于 Infinity)
因为 Infinity 在内部已经对文本检索和向量检索的分数进行了归一化处理,所以直接赋值输出即可。
# Don't need rerank here since Infinity normalizes each way score before fusion.
sim = [sres.field[id].get("_score", 0.0) for id in sres.ids]
sim = [s if s is not None else 0. for s in sim]
tsim = sim
vsim = sim
重排序流程结束后,会得到三个相似度:
- sim:混合相似度
- tsim:文本相似度
- vsim:向量相似度
4. chunk 列表构建输出
4.1 分页和排序
根据用户传参返回对应重排序结果,并进行相似度降序排列。
max_pages = RERANK_LIMIT // page_size
page_index = (page % max_pages) - 1
begin = max(page_index * page_size, 0)
sim = sim[begin : begin + page_size]
# 按相似度降序排序
sim_np = np.array(sim)
idx = np.argsort(sim_np * -1)
4.2 相似度阈值过滤
根据设置的相似度阈值过滤低于阈值的结果。
dim = len(sres.query_vector)
vector_column = f"q_{dim}_vec"
zero_vector = [0.0] * dim
filtered_count = (sim_np >= similarity_threshold).sum()
ranks["total"] = int(filtered_count) # Convert from np.int64 to Python int otherwise JSON serializable error
for i in idx:
if sim[i] < similarity_threshold:
break
4.3 构建单条数据结构
d = {
"chunk_id": id,
"content_ltks": chunk["content_ltks"], # 分词后的内容
"content_with_weight": chunk["content_with_weight"], # 带权重的内容
"doc_id": did,
"docnm_kwd": dnm,
"kb_id": chunk["kb_id"],
"important_kwd": chunk.get("important_kwd", []), # 重要关键词
"image_id": chunk.get("img_id", ""), # 关联图片ID
"similarity": sim[i], # 最终相似度
"vector_similarity": vsim[i], # 向量相似度
"term_similarity": tsim[i], # 文本相似度
"vector": chunk.get(vector_column, zero_vector), # 向量数据
"positions": position_int, # 在文档中的位置
"doc_type_kwd": chunk.get("doc_type_kwd", "") # 文档类型
}
4.4 返回列表
添加文档聚合信息,按照降序排列输出。文档聚合信息包含每个文档在最终结果中出现了多少个 chunk。
ranks["chunks"].append(d)
if dnm not in ranks["doc_aggs"]:
ranks["doc_aggs"][dnm] = {"doc_id": did, "count": 0}
ranks["doc_aggs"][dnm]["count"] += 1
ranks["doc_aggs"] = [{"doc_name": k,
"doc_id": v["doc_id"],
"count": v["count"]} for k, v in
sorted(ranks["doc_aggs"].items(),
key=lambda x: x[1]["count"] * -1)] # 按count降序
ranks["chunks"] = ranks["chunks"][:page_size]
return ranks