09-关键词与向量结合-Hybrid-Search混合检索实战
关键词检索与向量检索结合:Hybrid Search(混合检索)实战
系列:从零构建企业 RAG 知识库(第 9 篇)
1. 为什么两种检索互补
向量检索擅长同义表达和语义相关;关键词检索擅长精确编号、产品名、错误码和罕见术语。
“无法登录系统” ↔ “认证失败” 向量检索可能更强 “ERR-1042” ↔ 文档中的 ERR-1042 关键词检索通常更可靠Hybrid Search 同时获取两路候选,再融合排名。它不是把两个分数直接相加,因为 BM25 与余弦分数的量纲不同。
2. 一个简化 BM25 实现
fromcollectionsimportCounterfromdataclassesimportdataclassfrommathimportlogimportredeftokenize(text:str)->list[str]:"""教学分词;生产中文检索应使用经过评测的分析器。"""returnre.findall(r"[a-z0-9_-]+|[\u4e00-\u9fff]",text.lower())@dataclass(frozen=True)classSearchDocument:document_id:strtenant_id:strtext:strvector:tuple[float,...]defbm25_scores(query:str,documents:list[SearchDocument],k1:float=1.5,b:float=0.75,)->dict[str,float]:ifnotdocuments:return{}tokenized={doc.document_id:tokenize(doc.text)fordocindocuments}avg_length=sum(map(len,tokenized.values()))/len(documents)query_terms=set(tokenize(query))scores={doc.document_id:0.0fordocindocuments}forterminquery_terms:containing=sum(termintokensfortokensintokenized.values())idf=log(1+(len(documents)-containing+0.5)/(containing+0.5))fordocumentindocuments:tokens=tokenized[document.document_id]frequency=Counter(tokens)[term]iffrequency==0:continuelength_factor=1-b+b*len(tokens)/max(avg_length,1)scores[document.document_id]+=idf*(frequency*(k1+1)/(frequency+k1*length_factor))returnscores这是教学实现,未覆盖生产搜索引擎的分词、字段权重和优化。
3. 向量排名
frommathimportsqrtdefcosine(left:tuple[float,...],right:tuple[float,...])->float:ifnotleftorlen(left)!=len(right):raiseValueError("向量维度不一致")denominator=sqrt(sum(x*xforxinleft))*sqrt(sum(x*xforxinright))return0.0ifdenominator==0elsesum(x*yforx,yinzip(left,right))/denominatordefvector_ranking(query_vector:tuple[float,...],documents:list[SearchDocument],)->list[str]:return[item.document_idforiteminsorted(documents,key=lambdadoc:(-cosine(query_vector,doc.vector),doc.document_id,),)]4. 使用 RRF 融合排名
Reciprocal Rank Fusion(倒数排名融合)只使用名次,不要求两路分数同尺度:
defreciprocal_rank_fusion(rankings:list[list[str]],rank_constant:int=60,)->list[tuple[str,float]]:ifrank_constant<=0:raiseValueError("rank_constant 必须大于 0")scores:dict[str,float]={}forrankinginrankings:forrank,document_idinenumerate(ranking,start=1):scores[document_id]=scores.get(document_id,0.0)+(1.0/(rank_constant+rank))returnsorted(scores.items(),key=lambdaitem:(-item[1],item[0]))60是常见示例参数,不是必须值,应通过查询集调优。
5. 完整的权限内混合检索
defhybrid_search(query:str,query_vector:tuple[float,...],documents:list[SearchDocument],tenant_id:str,allowed_documents:frozenset[str],top_k:int=5,)->list[SearchDocument]:# 两路检索使用完全相同的授权候选集合candidates=[docfordocindocumentsifdoc.tenant_id==tenant_idanddoc.document_idinallowed_documents]ifnotcandidates:return[]lexical_scores=bm25_scores(query,candidates)lexical_rank=[keyforkey,scoreinsorted(lexical_scores.items(),key=lambdaitem:(-item[1],item[0]),)ifscore>0]semantic_rank=vector_ranking(query_vector,candidates)fused_ids=[document_idfordocument_id,_inreciprocal_rank_fusion([lexical_rank,semantic_rank])[:top_k]]document_map={doc.document_id:docfordocincandidates}return[document_map[document_id]fordocument_idinfused_ids]6. 可复验测试
deftest_rare_error_code_is_recovered()->None:documents=[SearchDocument("semantic","t1","登录认证失败处理",(1.0,0.0)),SearchDocument("exact","t1","错误码 ERR-1042 修复步骤",(0.0,1.0)),SearchDocument("other","t2","ERR-1042 内部秘密",(0.0,1.0)),]result=hybrid_search("ERR-1042",query_vector=(1.0,0.0),documents=documents,tenant_id="t1",allowed_documents=frozenset({"semantic","exact","other"}),top_k=2,)ids=[item.document_idforiteminresult]assert"exact"inidsassert"other"notinids7. 如何评测混合检索
分别报告关键词、向量和混合三组结果:
- Recall@K:正确证据是否进入前 K;
- MRR:第一个正确结果排在多前;
- NDCG:多个相关等级的排序质量;
- 零结果率、P95 延迟和检索成本;
- 按编号查询、自然语言、错别字和多语言切片。
如果混合结果没有优于单路,就不应仅因架构更复杂而上线。
8. 对抗性审查
- 两路检索必须使用相同权限过滤;
- 不把两种原始分数直接相加;
- 关键词索引和向量索引更新应保持版本一致;
- 对重复 Chunk 去重;
- 恶意文档可堆砌关键词,需要来源质量和重排;
- 查询日志不默认保存敏感原文。
9. 总结
混合检索用关键词守住精确匹配,用向量扩展语义召回,再用稳定融合算法组合候选。它提升的是召回候选质量,最终仍需要重排和生成校验。
