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

发布时间:2026/10/8 22:13:45

【智能体开发】如何提升关键词类问题的召回:实现关键词检索与向量检索的结果融合 如何提升关键词类问题的召回实现关键词检索与向量检索的结果融合一、一个具体的问题假设你正在维护一个产品文档检索系统。用户输入“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
延伸阅读

更多相关文章

2026/10/8 22:13:45

Fnet 云网安接入 TaoToken:统一 Key 打通云网安 API 调用链路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/8 22:08:44

Python字典详解:从哈希表原理到实战应用

接触Python这么久,我越来越觉得字典(Dictionaries)是这个语言里最被低估的数据结构。列表负责有序地装东西,元组负责不可变地保护东西,而字典负责高效地“按名字找人”。如果列表是一个一个排好队的柜子,字…

2026/10/9 0:29:30

V100 16G跑27B量化模型实测:理论极限与标题水分的差距

先别急着高兴。我在硬件群里看到“单卡 V100 16G 跑 Qwen3.8-27B 量化:1000 t/s prefill、60 decode、256K 上下文”这个标题时,第一反应不是“老卡又封神”,而是“这数字到底怎么算出来的”。V100 是 2017 年的 Volta 架构,16GB …

2026/10/9 0:29:30

AI资讯日更工作流:结构化、可审计、面向开发者的技术简报系统

1. 项目概述:这不是一份“新闻稿”,而是一套可复用的AI资讯日更工作流“2026-09-28 AI最新资讯日报”这个标题乍看像一张过期报纸的存档,但真正有经验的人一眼就能看出——它根本不是内容成品,而是一个高度结构化的日期占位符领域…

2026/10/9 0:29:30

RAG系统验收别只看一个指标:从多选题命中率到多维评测的实战复盘

上一周我们团队在药企客户现场做 RAG 系统验收。同一个知识库、同一批测试问题、同一条模型链路,内部自动化评测跑出来的多选题命中率是 95 分,业务方换了一套更严格的评分规则复核,成绩掉到 85 分。会议室里的气氛一下就紧张了。这个差距不是…

2026/10/9 0:29:30

AI Agent可观测性实战:用LangSmith实现全链路追踪与调试

做 AI Agent 的人,多半都有同一种感受:模型效果不好不可怕,最怕的是出了 Bug 你根本没法查。传统后端出问题,打开日志、看报错、翻调用链,基本能定位。Agent 应用完全是另一回事——一次回答背后可能调用了十几次甚至几…

2026/10/9 0:29:30

AI芯片为何偏爱脉动阵列:从矩阵乘法到TPU的架构解析

1. 从矩阵乘法说起:为什么AI芯片偏偏看上了脉动阵列很多人第一次听到“脉动阵列”这个词,脑子里浮现的是一排排整齐的运算单元像心跳一样同步跳动的画面。这个直觉其实相当准确。要理解它为什么在AI芯片领域被反复提起,得先回到一个最朴素的问…

2026/10/9 0:24:30

Fine语言中math.asin()函数详解:从定义到报表避坑实战

先说明一下标题里的一个经典误会:math.asin(x)算的其实是反正弦函数,也就是“已知正弦值,反推角度”,返回的是弧度。至于“计算 x 的正弦值”,那是math.sin(x)的活儿——别看这俩名字只差一个字母,用错的人…

2026/10/8 10:03:18

Jev+Agent接管浏览器:browser-use实战与jev-ultrafast性能优化

1. 从“Jev”说起:为什么我要把Agent接进浏览器“Jev”这个词最近在圈子里出现的频率越来越高,很多人第一次听到会以为是某个新模型的名字,其实它更像是一种思路——把Jev模型的能力当作底座,通过Agent的方式去接管浏览器&#xf…

2026/10/8 10:03:20

多智能体集群实战:DeepAgents编排、MCP与A2A协议及Skills体系

1. 从"单兵作战"到"集群协同":多智能体编排到底在解决什么问题如果你最近在折腾 Agent 相关的东西,大概率会有一种感觉:单个 Agent 能做的事情,其实很快就摸到天花板了。你给它一个提示词,挂几个工…

2026/10/8 6:05:44

无源低通滤波器设计实战:从RC到LC,手把手教你避开那些坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/9 0:04:27

毕业论文初稿完成后首次进行AIGC疑似度自查的摸底与分流策略

毕业论文初稿完成后首次进行AIGC疑似度自查的摸底与分流策略当数万字的学位论文初稿经历开题、实验、问卷与多轮文献梳理最终成形时,绝大多数研究生都会面临一道全新的形式审查关卡:AIGC 疑似度排查。在高校毕业审核流程中,盲审前的文本检测通…

2026/10/9 0:04:27

食堂节能改造源头工厂,商用厨房设备焕新方案广受好评

商用厨房作为餐饮经营、单位供餐的核心后勤阵地,其设备配置、动线规划与运维体系直接决定后厨作业效率、运营成本与合规性。从基础的灶具、制冷存储设备,到油烟净化、水处理等配套系统,每一个环节的合理性都与食品安全、能耗管控、消防安全挂…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑