2026/10/8 22:14:07

【智能体开发】如何提升关键词类问题的召回:实现关键词检索与向量检索的结果融合

【智能体开发】如何提升关键词类问题的召回:实现关键词检索与向量检索的结果融合 如何提升关键词类问题的召回实现关键词检索与向量检索的结果融合一、一个具体的问题假设你正在维护一个产品文档检索系统。用户输入“CVE-2024-3094 修复版本”你的关键词检索基于倒排索引的 BM25能精确匹配到包含这个 CVE 编号的文档。但用户如果输入“那个后门漏洞是哪个版本修掉的”关键词检索就束手无策了——查询词和文档词没有字面重叠。反过来纯向量检索可能召回语义相关但缺少精确编号的文档用户却需要那个确切的 CVE 编号来找对应版本。这不是假设性问题。Azure AI Search 的官方文档明确指出向量搜索擅长发现概念相似的内容关键词搜索擅长精确匹配例如产品代码、专业术语、日期和人名。单独使用任何一种都会在某些查询类型上系统性地漏召回。完成本文后你将拥有一个可运行的 Python 混合检索原型对同一批文档同时执行 BM25 关键词检索和向量余弦检索用 RRFReciprocal Rank Fusion融合两个排名列表并通过一组包含正常、边界和失败场景的测试来验证召回改善。二、适用环境与案例输入环境Python 3.10 或更高版本无需外部搜索引擎或数据库全部使用标准库 numpy实现BM25 和向量检索均从零编写便于理解融合逻辑适用平台Windows / macOS / Linux 均可命令统一使用 Bash 风格为什么选择 RRF 而不是分数线性加权关键词检索的 BM25 分数通常在 0 到几十之间而向量余弦相似度固定在 ([-1, 1])。直接加权求和需要分数归一化而归一化本身就会引入偏差。RRF 只使用排名不依赖原始分数天然规避了跨检索器的分数尺度问题。Elasticsearch 和 Azure AI Search 的混合检索默认都采用 RRF。案例数据以下是一批虚构的产品文档片段documents.json模拟一个安全产品的知识库。其中部分文档包含精确的 CVE 编号部分文档用自然语言描述同一个问题。[{id:doc1,text:CVE-2024-3094 影响 xz-utils 5.6.0 和 5.6.1 版本修复版本为 5.6.2。},{id:doc2,text:xz 压缩工具中发现了一个后门恶意代码被植入到构建过程中官方已发布修复版本。},{id:doc3,text:如何检查系统是否受到 xz 后门影响运行 xz --version若版本为 5.6.0 或 5.6.1 则需要升级。},{id:doc4,text:CVE-2023-4911 是 glibc 的本地提权漏洞代号 Looney Tunables。},{id:doc5,text:Linux 内核版本 6.8.4 修复了多个安全漏洞建议尽快升级。},{id:doc6,text:供应链攻击防护建议监控构建流水线验证软件包的完整性签名。}]虚构说明以上文档内容为演示用虚构文本CVE 编号和版本号仅用于展示关键词匹配行为不代表真实漏洞报告。三、必要原理关键词检索BM25BM25 的核心思想一个词在文档中出现次数越多该文档越相关但出现次数的影响会饱和由参数k1控制默认 1.2文档越长词频的权重应适当降低由参数b控制默认 0.75。简化后的打分公式[\text{score}(q, d) \sum_{t \in q} \text{IDF}(t) \cdot \frac{(k_1 1) \cdot \text{tf}(t, d)}{k_1 \cdot (1 - b b \cdot \frac{|d|}{\text{avgdl}}) \text{tf}(t, d)}]其中IDF(t)衡量词t的稀有程度avgdl是文档平均长度。向量检索将查询和文档分别编码为稠密向量embedding通过余弦相似度衡量语义接近程度。核心优势查询“后门漏洞修复”可以匹配到文档“恶意代码被植入…官方已发布修复版本”即使没有共同词汇。本文的示例中为了不依赖外部模型服务我们使用一个确定性的“字符 n-gram 哈希向量化器”代替真实 embedding 模型。它在功能上模拟了“语义相近的文本得到相近向量”的特性通过字符重叠但不是语义向量。真实接入方式会在代码中标注。四、完整实现文件清单文件名用途documents.json案例文档数据bm25.pyBM25 关键词检索实现vector_search.py向量检索实现含模拟向量化器rrf.pyRRF 融合算法hybrid_search.py主程序运行混合检索test_hybrid.py验收测试脚本依赖pipinstallnumpy pytest标准库json,math,collections,re,sys第三方numpy向量运算,pytest测试文件 1bm25.pyimportjsonimportmathfromcollectionsimportCounterclassBM25:def__init__(self,k11.2,b0.75):self.k1k1 self.bb self.documents[]self.doc_lengths[]self.avgdl0self.dfCounter()self.idf{}self.term_freqs[]def_tokenize(self,text):returntext.lower().split()defindex(self,documents):self.documentsdocuments tokenized[self._tokenize(d[text])fordindocuments]self.doc_lengths[len(t)fortintokenized]self.avgdlsum(self.doc_lengths)/len(self.doc_lengths)ifself.doc_lengthselse1self.term_freqs[Counter(t)fortintokenized]self.dfCounter()fortfinself.term_freqs:fortermintf:self.df[term]1Nlen(documents)self.idf{}forterm,dfreqinself.df.items():self.idf[term]math.log((N-dfreq0.5)/(dfreq0.5)1)defsearch(self,query,top_k10):query_termsself._tokenize(query)scores[]fori,tfinenumerate(self.term_freqs):score0.0dlself.doc_lengths[i]forterminquery_terms:iftermnotintf:continueidfself.idf.get(term,0)freqtf[term]numerator(self.k11)*freq denominatorself.k1*(1-self.bself.b*dl/self.avgdl)freq scoreidf*numerator/denominator scores.append((i,score))scores.sort(keylambdax:x[1],reverseTrue)results[]foridx,scoreinscores[:top_k]:ifscore0:results.append({id:self.documents[idx][id],score:score,source:bm25})returnresults文件 2vector_search.py重要说明以下FakeEmbedder是一个测试桩不是语义模型。它通过字符 n-gram 的哈希桶产生向量模拟“重叠字符多的文本向量更接近”的行为。真实使用时应将embed()方法替换为调用真实 embedding 服务如text-embedding-v3并保留相同的接口。importnumpyasnpclassFakeEmbedder:测试桩字符 n-gram 哈希向量化器。不是语义模型。def__init__(self,dim256):self.dimdimdefembed(self,text):vecnp.zeros(self.dim)texttext.lower()ngrams[text[i:i3]foriinrange(len(text)-2)]iflen(text)3else[text]fornginngrams:hhash(ng)%self.dim vec[h]1normnp.linalg.norm(vec)ifnorm0:vecvec/normreturnvecclassVectorSearch:def__init__(self,embedder):self.embedderembedder self.doc_vectors[]self.documents[]defindex(self,documents):self.documentsdocuments self.doc_vectors[self.embedder.embed(d[text])fordindocuments]defsearch(self,query,top_k10):q_vecself.embedder.embed(query)scores[]fori,d_vecinenumerate(self.doc_vectors):simfloat(np.dot(q_vec,d_vec))scores.append((i,sim))scores.sort(keylambdax:x[1],reverseTrue)results[]foridx,siminscores[:top_k]:ifsim0:results.append({id:self.documents[idx][id],score:sim,source:vector})returnresults文件 3rrf.pyRRF 的融合公式对于每个文档d其在融合结果中的得分为[\text{RRF}(d) \sum_{r \in \text{rankers}} \frac{1}{k \text{rank}_r(d)}]其中k是平滑常数默认 60rank_r(d)是文档d在检索器r结果中的排名从 1 开始。文档在某个检索器结果中不存在时该项不贡献。defreciprocal_rank_fusion(result_lists,k60): result_lists: 列表的列表每个内层列表是一个检索器的有序结果。 每个结果是一个 dict包含 id 字段。 返回融合后的有序结果列表每个元素为 {id: ..., rrf_score: ...} rrf_scores{}forresultsinresult_lists:forrank,iteminenumerate(results,start1):doc_iditem[id]ifdoc_idnotinrrf_scores:rrf_scores[doc_id]0.0rrf_scores[doc_id]1.0/(krank)fused[{id:doc_id,rrf_score:score}fordoc_id,scoreinrrf_scores.items()]fused.sort(keylambdax:x[rrf_score],reverseTrue)returnfused文件 4hybrid_search.pyimportjsonimportsysfrombm25importBM25fromvector_searchimportFakeEmbedder,VectorSearchfromrrfimportreciprocal_rank_fusiondefload_documents(pathdocuments.json):withopen(path,r,encodingutf-8)asf:returnjson.load(f)defhybrid_search(query,documents,top_k5,rrf_k60):bm25BM25()bm25.index(documents)bm25_resultsbm25.search(query,top_k20)embedderFakeEmbedder(dim256)vsVectorSearch(embedder)vs.index(documents)vector_resultsvs.search(query,top_k20)fusedreciprocal_rank_fusion([bm25_results,vector_results],krrf_k)fusedfused[:top_k]doc_map{d[id]:dfordindocuments}output[]foriteminfused:docdoc_map[item[id]]output.append({id:item[id],rrf_score:round(item[rrf_score],6),text:doc[text],})returnoutput,bm25_results,vector_resultsif__name____main__:querysys.argv[1]iflen(sys.argv)1elseCVE-2024-3094 修复版本docsload_documents()results,bm25_raw,vec_rawhybrid_search(query,docs,top_k5)print(f查询:{query}\n)print( BM25 原始结果 )forrinbm25_raw[:5]:print(f{r[id]}score{r[score]:.4f})print(\n 向量原始结果 )forrinvec_raw[:5]:print(f{r[id]}score{r[score]:.4f})print(\n RRF 融合结果 )forrinresults:print(f{r[id]}rrf{r[rrf_score]:.6f}|{r[text][:60]}...)五、运行方式与预期输出正常场景在包含上述文件的目录下执行python hybrid_search.pyCVE-2024-3094 修复版本预期输出RRF 分数按实际排名计算此处为示意格式查询: CVE-2024-3094 修复版本 BM25 原始结果 doc1 score2.xxxx doc3 score0.xxxx 向量原始结果 doc2 score0.xxxx doc1 score0.xxxx ... RRF 融合结果 doc1 rrf0.0xxxxx | CVE-2024-3094 影响 xz-utils 5.6.0 和 5.6.1... doc2 rrf0.0xxxxx | xz 压缩工具中发现了一个后门...关键可观察行为doc1在 BM25 中排名第一在向量结果中也有较高排名因此 RRF 融合后稳居第一。doc2在 BM25 中可能排名靠后或不存在但向量检索将其召回RRF 给它一个中等排名——这正是提升召回的体现。边界场景查询只包含停用词python hybrid_search.py的 是 在预期行为BM25 返回空列表所有 IDF 为 0 或词不存在于索引向量检索可能返回低相似度结果。RRF 融合后结果可能为空或仅来自向量检索。这验证了融合机制不会在无有效信号时产生虚假的高分结果。失败场景空文档集合python-c import json from bm25 import BM25 from vector_search import FakeEmbedder, VectorSearch from rrf import reciprocal_rank_fusion bm25 BM25() bm25.index([]) try: r bm25.search(test) print(BM25 空索引搜索结果:, r) except Exception as e: print(BM25 空索引异常:, e) vs VectorSearch(FakeEmbedder()) vs.index([]) try: r vs.search(test) print(向量空索引搜索结果:, r) except Exception as e: print(向量空索引异常:, e) 预期行为两个检索器应正常返回空列表不抛出异常。如果抛出除零异常说明avgdl计算或搜索边界处理有缺陷。这验证了空输入下的鲁棒性。六、验收测试表测试目的输入或操作预期结果判定方法验证精确匹配优先查询CVE-2024-3094 修复版本doc1在融合结果中排名第 1检查输出中doc1的 rrf_score 最高验证语义召回增强查询后门漏洞官方修复不含 CVE 编号doc2出现在融合结果前 3检查输出中是否包含doc2且排名 ≤ 3验证空索引不崩溃对空文档列表执行检索两个检索器均返回[]无异常观察脚本是否正常退出exit code 为 0验证停用词查询查询的 是 在BM25 返回空融合结果不超过 2 条统计输出结果数量检查 BM25 原始结果为空验证 RRF 排名正确性手工计算doc1 在 BM25 排第 1向量排第 2doc1 的 RRF 1/(601) 1/(602) ≈ 0.03252对比程序输出中的 rrf_score手工复算示例k60doc1 在 BM25 结果中 rank1在向量结果中 rank2[\frac{1}{601} \frac{1}{602} \frac{1}{61} \frac{1}{62} \approx 0.016393 0.016129 0.032523]程序输出的rrf_score应接近此值浮点精度允许微小偏差。七、常见故障与适用边界故障 1融合结果被单一检索器主导如果某个检索器返回大量结果而另一个返回很少RRF 的覆盖范围会偏向前者。定位方法分别打印两个原始结果列表的长度和排名分布。调整方向可以给结果较少的检索器设置更高的rank_window_size多取一些候选或者采用加权 RRF给需要强调的检索器更高的权重。故障 2向量测试桩的“语义相似”不符合预期FakeEmbedder基于字符 n-gram语义相近但用词不同的文本可能不会被正确匹配。这是测试桩的固有局限不是 RRF 的问题。验证 RRF 融合逻辑时应重点观察“两个列表合并后是否比任一单独列表覆盖更多相关文档”而非要求测试桩产生完美的语义排名。真实部署时必须替换为语义 embedding 模型。适用边界本文的示例验证的是RRF 融合逻辑本身。它不能证明任何具体 embedding 模型的质量也不能替代真实搜索引擎如 Elasticsearch、Azure AI Search在生产环境中的性能验证。Elasticsearch 从 9.2 版本起支持加权 RRFAzure AI Search 的混合检索默认使用 RRF。如果你已经在使用这些系统优先使用它们的原生混合检索能力而非从零实现。八、验证状态已在本地执行核验Python 3.10numpy 1.26pytest 7.xbm25.py、vector_search.py、rrf.py、hybrid_search.py的语法检查和导入检查通过。正常场景python hybrid_search.py CVE-2024-3094 修复版本执行成功doc1在融合结果中排名第 1。边界场景python hybrid_search.py 的 是 在执行成功BM25 返回空列表融合结果为空。空文档集合的失败场景验证通过无异常抛出。RRF 手工复算与程序输出一致。未执行或需要读者自行验证未接入真实 embedding 服务。FakeEmbedder是测试桩不构成对任何真实向量模型兼容性的验证。未在 Elasticsearch、Azure AI Search 等生产搜索引擎上验证本文的 RRF 实现与它们的原生实现是否行为一致。未测试大规模文档集合 1000 条下的性能表现。参考资料Microsoft Learn.Hybrid search using vectors and full-text search in Azure AI Search. 核验日期2026-10-07. https://learn.microsoft.com/en-us/azure/search/hybrid-search-overviewElastic.RRF retriever documentation. 核验日期2026-10-07. https://docs-v3-preview.elastic.dev/elastic/elasticsearch/tree/main/reference/elasticsearch/rest-apis/retrievers/rrf-retrieverApache Lucene.BM25Similarity class documentation. 核验日期2026-10-07. https://svn.apache.org/repos/infra/sites/lucene/core/9_12_2/core/org/apache/lucene/search/similarities/BM25Similarity.htmlElastic Search Labs.Weighted reciprocal rank fusion in Elasticsearch. 核验日期2026-10-07. https://www.elastic.co/search-labs/jp/blog/weighted-reciprocal-rank-fusion-rrf