retrieval 函数
接收用户 query,返回最相关的 Top K 个文档片段,附带相似度分数和来源信息。
具体分五个步骤
构建请求
把用户的 query、知识库 ID、相似度阈值、分页参数等打包成一个请求对象。
混合检索(向量+BM25 并行)
同时发起向量检索和 BM25 关键词检索,用融合算法合并结果。
Rerank 精排
对合并后的候选结果用 Cross-Encoder 模型重新打分排序。
阈值过滤返回
过滤掉相似度低于阈值的结果,避免返回不相关内容。
组装返回
把最终结果(文本内容、相似度分数、文档来源、页码等)打包返回。下面逐步拆解。
构建检索请求
def retrieval(self, question, embd_mdl, tenant_ids, kb_ids,
page, page_size, similarity_threshold=0.2,
vector_similarity_weight=0.3, top=1024,
rerank_mdl=None, highlight=False):
ranks = {"total": 0, "chunks": [], "doc_aggs": {}}
RERANK_PAGE_LIMIT = 3
req = {
"kb_ids": kb_ids,
"size": max(page_size * RERANK_PAGE_LIMIT, 128),
"question": question,
"vector": True,
"topk": top,
"similarity": similarity_threshold,
}
关键参数
size 为什么设成 page_size × 3
因为前 3 页要做 Rerank 精排
Rerank 会重新排序,原来排第 15 的可能被提到第 1——所以必须先拉回足够多的候选(至少 3 页的量),精排后再截取当前页
RERANK_PAGE_LIMIT = 3
只对前 3 页做精排
第 4 页及以后直接用初步检索的排序,不再调 Cross-Encoder
因为精排计算成本高,用户翻到第 4 页的概率很低,不值得花这个算力
top = 1024
向量库最多返回 1024 条,保证候选池足够大
混合检索的实现
def get_vector(self, txt, emb_mdl, topk=10, similarity=0.1):<br> # 对query文本生成Embedding向量<br> qv = generate_embedding(txt)<br> embedding_data = [float(v) for v in qv]<br> <br> # 构造向量匹配表达式<br> vector_column_name = f"q_{len(embedding_data)}_vec"<br> return MatchDenseExpr(vector_column_name, embedding_data,<br> 'float', 'cosine', topk,<br> {"similarity": similarity})
search 方法内部同时执行了向量检索和 BM25 检索,然后融合
向量检索部分
def get_vector(self, txt, emb_mdl, topk=10, similarity=0.1):<br> # 对query文本生成Embedding向量<br> qv = generate_embedding(txt)<br> embedding_data = [float(v) for v in qv]<br> <br> # 构造向量匹配表达式<br> vector_column_name = f"q_{len(embedding_data)}_vec"<br> return MatchDenseExpr(vector_column_name, embedding_data,<br> 'float', 'cosine', topk,<br> {"similarity": similarity})
向量是 0-1 的余弦相似度
BM25 检索部分
对 query 做分词和关键词提取后,构造一个 MatchTextExpr,用 Elasticsearch 的 query_string 查询
Elasticsearch 的索引配置
BM25 检索的效果很大程度上取决于 ES 的索引配置
自定义分词器 + 领域词典
jieba 默认会把"意外伤害"切成"意外"+"伤害",把"犹豫期"切成"犹豫"+"期"
加入自定义词典后,这些术语会被当作完整的词保留
例如
默认切分带来的灾难: * 用户搜:“犹豫期内退保怎么扣钱?”
默认 jieba 切词:“犹豫” + “期” + “内” + “退保”...
结果: 搜索引擎拿着“犹豫”和“期”去库里找。它可能会给你找出一篇这样的文章:“客户对于购买期货产品感到非常犹豫”。
问题所在: 这两个字拆开后,完全丧失了保险领域里“冷静期”的专业含义,导致搜出来的东西驴唇不对马嘴(准确率低),或者真正相关的文章因为没有同时高频出现“犹豫”和“期”而没被找出来(召回率低)。
自定义词典就像是给分词器发了一本“保险业务操作手册”。
你明确告诉 jieba:“意外伤害”、“犹豫期”这是一个不可分割的整体词汇(Token),不要给我切开。
改进后: 当系统再次看到“犹豫期内退保”,它切出来的是 ["犹豫期", "内", "退保"]。搜索引擎就会严格去寻找包含“犹豫期”这个完整概念的文章。
同义词过滤器
在 ES 分析器中配置同义词
例如
孩子,儿童,小孩,未成年人 会被当成等价词处理
用户搜"孩子摔伤"也能匹配到包含"未成年人意外伤害"的文档
字段权重
章节标题字段(section_title)的权重设为正文的 2 倍
如果查询词出现在标题中,说明这个 Chunk 的主题就是用户要找的内容,应该排在前面
关键配置是 minimum_should_match——控制查询词中至少有多少比例必须匹配
BM25 是无上界的 TF-IDF 分数
两路结果的权重分配
# 向量检索的权重
vector_weight = vector_similarity_weight # 默认0.3
# BM25的权重 = 1 - 向量权重
text_weight = 1.0 - vector_similarity_weight # 默认0.7
BM25 更高的权重原因
BM25 的关键词匹配对这类查询更有效
如果你的场景以语义查询为主,应该反过来给向量更高权重
融合算法:RRF(Reciprocal Rank Fusion)
RRF 的巧妙之处在于它只看排名不看分数
两路检索返回的分数量纲不同(向量是 0-1 的余弦相似度,BM25 是无上界的 TF-IDF 分数),不能直接相加。RRF 的巧妙之处在于它只看排名不看分数:
def rrf_fusion(vector_results, bm25_results, k=60):
doc_scores = {}
# 向量检索的排名贡献
for rank, (doc_id, _) in enumerate(vector_results, start=1):
doc_scores[doc_id] = 1 / (k + rank)
# BM25的排名贡献
for rank, (doc_id, _) in enumerate(bm25_results, start=1):
if doc_id in doc_scores:
doc_scores[doc_id] += 1 / (k + rank)
else:
doc_scores[doc_id] = 1 / (k + rank)
# 按融合分数排序
return sorted(doc_scores.items(), key=lambda x: x[1], reverse=True)
关键参数
k=60
k 是一个平滑参数,值越大排名靠后的结果贡献越小
k=60 是信息检索领域的经验值,在大多数 benchmark 上表现最优
测试k=20 和 k=100,差异在 1% 以内,所以直接用默认值。
RRF vs 加权求和的对比
RRF 的优势在于不需要归一化、不需要调权重参数、对异常分数不敏感。
子主题
Rerank 精排的分页策略
精排是整个检索流程中计算最昂贵的环节——每个候选都要跟 query 拼接后过一次 Cross-Encoder。所以必须控制精排的候选数量。
if page <= RERANK_PAGE_LIMIT: # 前3页做精排<br> if sres.total > 0:<br> sim, tsim, vsim = self.rerank_by_model(<br> rerank_mdl, sres, question,<br> 1 - vector_similarity_weight, # token权重<br> vector_similarity_weight, # 向量权重<br> )<br> # 精排后按分数重新排序,截取当前页<br> idx = np.argsort(sim * -1)[(page-1)*page_size : page*page_size]<br>else:<br> # 第4页及以后,跳过精排,直接用初步排序<br> sim = tsim = vsim = [1] * len(sres.ids)<br> idx = list(range(len(sres.ids)))
rerank_by_model 内部做了什么
它把 query 和每个候选 Chunk 拼接成 (query, document) 对
送入 Cross-Encoder(比如 bge-reranker-large)做相关性打分
然后把 Cross-Encoder 的分数跟<br>原始的 token 相似度、<br>向量相似度做加权融合,<br>得到最终的综合分数
tsim
纯 token 相似度(BM25 维度的贡献)
vsim
纯向量相似度(Embedding 维度的贡献)
过滤与组装
for i in idx:
# 阈值过滤:分数太低的直接跳过
if sim[i] < similarity_threshold:
break
# 达到当前页数量就停
if len(ranks["chunks"]) >= page_size:
break
chunk = sres.field[id]
d = {
"chunk_id": id,
"content_ltks": chunk["content_ltks"],
"doc_id": chunk.get("doc_id", ""),
"docnm_kwd": chunk.get("docnm_kwd", ""),
"kb_id": chunk["kb_id"],
"similarity": sim[i], # 综合分
"vector_similarity": vsim[i], # 向量分
"term_similarity": tsim[i], # 关键词分
"positions": position_int, # 原文位置
}
ranks["chunks"].append(d)
# 文档聚合统计
if dnm notin ranks["doc_aggs"]:
ranks["doc_aggs"][dnm] = {"doc_id": did, "count": 0}
ranks["doc_aggs"][dnm]["count"] += 1
doc_aggs
统计每个文档被召回了多少个 Chunk
如果某个文档的 Chunk 被大量召回
说明这个文档跟 query 高度相关
这个信息可以用于前端展示"最相关的文档"
总结
retrieval 函数分五步
构建请求 → 混合检索(向量+BM25 并行)→ RRF 融合 → Rerank 精排 → 阈值过滤返回
混合检索
向量检索用 BGE-M3 生成 Embedding,在 Milvus 的 HNSW 索引上做 cosine 搜索;BM25 用 Elasticsearch 的 query_string,配了自定义分词器和保险领域同义词。两路结果用 RRF 融合——只看排名不看分数,避免了分数量纲不一致的问题,k=60 是经验值。
Rerank 策略
不是所有结果都做精排——只对前 3 页做 Cross-Encoder 重排,第 4 页及以后跳过。因为精排计算成本高,用户翻到后面页的概率很低。精排后的综合分数是 token 相似度、向量相似度和 Cross-Encoder 分数的加权融合。
量化效果
RRF 比加权求和 MRR 高 3 个百分点;自定义分词器让 BM25 召回率从 0.79 提到 0.84;整套组合的最终 MRR 达到 0.92。