三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

09-关键词与向量结合-Hybrid-Search混合检索实战

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"notinids

7. 如何评测混合检索

分别报告关键词、向量和混合三组结果:

  • Recall@K:正确证据是否进入前 K;
  • MRR:第一个正确结果排在多前;
  • NDCG:多个相关等级的排序质量;
  • 零结果率、P95 延迟和检索成本;
  • 按编号查询、自然语言、错别字和多语言切片。

如果混合结果没有优于单路,就不应仅因架构更复杂而上线。

8. 对抗性审查

  • 两路检索必须使用相同权限过滤;
  • 不把两种原始分数直接相加;
  • 关键词索引和向量索引更新应保持版本一致;
  • 对重复 Chunk 去重;
  • 恶意文档可堆砌关键词,需要来源质量和重排;
  • 查询日志不默认保存敏感原文。

9. 总结

混合检索用关键词守住精确匹配,用向量扩展语义召回,再用稳定融合算法组合候选。它提升的是召回候选质量,最终仍需要重排和生成校验。

← 返回列表