纯向量检索的局限

向量检索(语义搜索)很强大,但有一个明显的弱点:对精确关键词匹配不敏感。例如:

BM25关键词检索原理

BM25是传统搜索引擎的核心算法,是TF-IDF的改进版。它的核心思想:

BM25不需要GPU,计算极快,对精确关键词命中非常可靠。缺点是完全不理解语义("汽车"和"轿车"对BM25来说是两个不同的词)。

混合检索:向量 + BM25双路

混合检索的思路:分别用向量检索和BM25检索各取Top-K结果,再用融合算法合并排序。两路检索互补:向量召回语义相关内容,BM25召回精确关键词命中内容。

RRF(倒数排名融合)算法

RRF是最常用的融合方法,简单且效果好:

RRF_score(d) = Σ 1 / (k + rank_i(d))

其中:
- k = 60(常数,防止高排名差距过大,通常固定为60)
- rank_i(d) = 文档d在第i路检索结果中的排名
- 对每路检索结果求和
    

排名越靠前的文档得分越高,多路都靠前的文档综合得分最高。

何时必须使用混合检索?

混合检索完整实现代码

pip install rank-bm25 chromadb openai
import numpy as np
from rank_bm25 import BM25Okapi
import chromadb
from openai import OpenAI

# ===== 准备数据 =====
documents = [
    "BGE-M3模型支持最长8192个token的输入",
    "RAG通过检索增强生成质量,减少幻觉",
    "向量数据库使用HNSW索引加速ANN检索",
    "BM25是传统搜索引擎的核心排序算法",
    "混合检索结合向量搜索和关键词搜索的优势",
]
doc_ids = [f"doc_{i}" for i in range(len(documents))]

client = OpenAI()

def get_embedding(text):
    resp = client.embeddings.create(
        model="text-embedding-3-small",
        input=text
    )
    return resp.data[0].embedding

# ===== 构建BM25索引 =====
# 中文需要先分词,这里用简单的字符级分词
tokenized_docs = [list(doc) for doc in documents]
bm25 = BM25Okapi(tokenized_docs)

# ===== 构建向量索引(Chroma)=====
chroma_client = chromadb.Client()
collection = chroma_client.create_collection("hybrid_search")

embeddings = [get_embedding(doc) for doc in documents]
collection.add(
    ids=doc_ids,
    documents=documents,
    embeddings=embeddings
)

# ===== RRF融合函数 =====
def rrf_fusion(rankings_list, k=60):
    """
    rankings_list: 每路检索返回的 [(doc_id, rank), ...] 列表
    返回:按RRF得分排序的 [(doc_id, score), ...]
    """
    scores = {}
    for rankings in rankings_list:
        for doc_id, rank in rankings:
            scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank)
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)

# ===== 混合检索主函数 =====
def hybrid_search(query, top_k=3):
    # --- 路1:BM25关键词检索 ---
    tokenized_query = list(query)
    bm25_scores = bm25.get_scores(tokenized_query)
    bm25_ranking = sorted(enumerate(bm25_scores), key=lambda x: x[1], reverse=True)
    bm25_results = [(doc_ids[idx], rank+1) for rank, (idx, _) in enumerate(bm25_ranking[:top_k*2])]

    # --- 路2:向量语义检索 ---
    query_embedding = get_embedding(query)
    vec_results = collection.query(
        query_embeddings=[query_embedding],
        n_results=top_k * 2
    )
    vector_results = [(doc_ids[int(id_.split("_")[1])], rank+1)
                     for rank, id_ in enumerate(vec_results["ids"][0])]

    # --- RRF融合 ---
    fused = rrf_fusion([bm25_results, vector_results])

    # 返回Top-K结果
    final_results = []
    for doc_id, score in fused[:top_k]:
        idx = int(doc_id.split("_")[1])
        final_results.append({
            "id": doc_id,
            "content": documents[idx],
            "rrf_score": score
        })
    return final_results

# ===== 测试 =====
query = "BGE-M3最大输入token数"
results = hybrid_search(query, top_k=3)
for r in results:
    print(f"[{r['rrf_score']:.4f}] {r['content']}")

使用LangChain的EnsembleRetriever(简化版)

from langchain.retrievers import BM25Retriever, EnsembleRetriever
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings

# 构建BM25 Retriever
bm25_retriever = BM25Retriever.from_texts(documents)
bm25_retriever.k = 5

# 构建向量 Retriever
vectorstore = Chroma.from_texts(documents, OpenAIEmbeddings())
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})

# 混合检索(权重:向量0.6,BM25 0.4)
ensemble = EnsembleRetriever(
    retrievers=[vector_retriever, bm25_retriever],
    weights=[0.6, 0.4]
)

results = ensemble.invoke("什么是BM25算法?")
for doc in results:
    print(doc.page_content)