跳转至

混合检索

演示 RAG 中向量检索与关键词检索(BM25 风格)的融合: - 向量检索捕获语义相似; - 关键词检索捕获字面命中; - 用倒数排名融合(Reciprocal Rank Fusion, RRF)合并两路结果。 全部基于标准库实现,便于离线测试。 对应教程:第6卷《RAG》第3章 检索策略。

文件路径: examples/volume-06-rag\hybrid_search.py


源代码

"""混合检索示例。

演示 RAG 中向量检索与关键词检索(BM25 风格)的融合:
    - 向量检索捕获语义相似;
    - 关键词检索捕获字面命中;
    - 用倒数排名融合(Reciprocal Rank Fusion, RRF)合并两路结果。

全部基于标准库实现,便于离线测试。

对应教程:第6卷《RAG》第3章 检索策略。
"""

from __future__ import annotations

import math
import re
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any


def tokenize(text: str) -> list[str]:
    """简易分词:英文单词 + 中文字符。"""
    text = text.lower()
    return re.findall(r"[a-z]+", text) + re.findall(r"[\u4e00-\u9fff]", text)


@dataclass
class Document:
    """检索文档。

    Attributes:
        id: 文档 ID。
        text: 文本。
        metadata: 元数据。
    """

    id: str
    text: str
    metadata: dict[str, Any] = field(default_factory=dict)


@dataclass
class KeywordIndex:
    """基于词频的简易倒排索引(BM25 简化版)。"""

    _docs: list[Document] = field(default_factory=list)
    _doc_tokens: list[list[str]] = field(default_factory=list)
    _df: dict[str, int] = field(default_factory=dict)  # 文档频率

    def add(self, docs: list[Document]) -> None:
        """批量添加文档。"""
        for doc in docs:
            tokens = tokenize(doc.text)
            self._docs.append(doc)
            self._doc_tokens.append(tokens)
            for term in set(tokens):
                self._df[term] = self._df.get(term, 0) + 1

    def search(self, query: str, top_k: int = 5) -> list[tuple[str, float]]:
        """BM25 风格检索。

        Args:
            query: 查询文本。
            top_k: 返回数量。

        Returns:
            ``(doc_id, score)`` 列表,按分数降序。
        """
        q_terms = tokenize(query)
        if not q_terms or not self._docs:
            return []
        n = len(self._docs)
        avg_len = sum(len(t) for t in self._doc_tokens) / n
        scores: dict[str, float] = {doc.id: 0.0 for doc in self._docs}
        k1, b = 1.5, 0.75
        for term in q_terms:
            df = self._df.get(term, 0)
            if df == 0:
                continue
            idf = math.log((n - df + 0.5) / (df + 0.5) + 1)
            for i, doc_tokens in enumerate(self._doc_tokens):
                tf = doc_tokens.count(term)
                if tf == 0:
                    continue
                denom = tf + k1 * (1 - b + b * len(doc_tokens) / max(avg_len, 1))
                scores[self._docs[i].id] += idf * tf * (k1 + 1) / denom
        ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
        return [(doc_id, s) for doc_id, s in ranked[:top_k] if s > 0]


@dataclass
class VectorRetriever:
    """向量检索器(复用哈希向量器思路,独立实现以避免跨卷依赖)。"""

    embedder: Callable[[str], list[float]]
    _docs: list[Document] = field(default_factory=list)
    _vectors: list[list[float]] = field(default_factory=list)

    def add(self, docs: list[Document]) -> None:
        """批量添加文档。"""
        for doc in docs:
            self._docs.append(doc)
            self._vectors.append(self.embedder(doc.text))

    def search(self, query: str, top_k: int = 5) -> list[tuple[str, float]]:
        """余弦相似度检索。

        Returns:
            ``(doc_id, score)`` 列表,按分数降序。
        """
        if not self._docs:
            return []
        q_vec = self.embedder(query)
        scored: list[tuple[str, float]] = []
        for doc, vec in zip(self._docs, self._vectors, strict=True):
            scored.append((doc.id, _cosine(q_vec, vec)))
        scored.sort(key=lambda x: x[1], reverse=True)
        return scored[:top_k]


def _cosine(a: list[float], b: list[float]) -> float:
    dot = sum(x * y for x, y in zip(a, b, strict=True))
    na = math.sqrt(sum(x * x for x in a))
    nb = math.sqrt(sum(y * y for y in b))
    if na == 0 or nb == 0:
        return 0.0
    return dot / (na * nb)


def reciprocal_rank_fusion(
    rankings: list[list[tuple[str, float]]],
    k: int = 60,
) -> list[tuple[str, float]]:
    """倒数排名融合(RRF)。

    对每个文档 ID,累加 ``1 / (k + rank)`` 作为融合分数,``rank`` 从 1 开始。

    Args:
        rankings: 多路检索结果,每路为 ``(doc_id, score)`` 列表(按分数降序)。
        k: RRF 平滑常数,默认 60。

    Returns:
        融合后的 ``(doc_id, rrf_score)`` 列表,按融合分数降序。
    """
    fused: dict[str, float] = {}
    for ranking in rankings:
        for rank, (doc_id, _score) in enumerate(ranking, start=1):
            fused[doc_id] = fused.get(doc_id, 0.0) + 1.0 / (k + rank)
    return sorted(fused.items(), key=lambda x: x[1], reverse=True)


def hybrid_search(
    query: str,
    docs: list[Document],
    embedder: Callable[[str], list[float]],
    top_k: int = 3,
) -> list[tuple[str, float]]:
    """执行混合检索:向量 + 关键词,并用 RRF 融合。

    Args:
        query: 查询文本。
        docs: 文档列表。
        embedder: 向量化函数。
        top_k: 最终返回数量。

    Returns:
        融合后的 ``(doc_id, score)`` 列表。
    """
    keyword_index = KeywordIndex()
    keyword_index.add(docs)
    vector_retriever = VectorRetriever(embedder=embedder)
    vector_retriever.add(docs)

    keyword_hits = keyword_index.search(query, top_k=max(top_k * 2, 5))
    vector_hits = vector_retriever.search(query, top_k=max(top_k * 2, 5))

    fused = reciprocal_rank_fusion([keyword_hits, vector_hits])
    return fused[:top_k]


def main() -> None:
    """演示混合检索。"""
    docs = [
        Document("d1", "AI Agent 能自主决策并执行任务"),
        Document("d2", "RAG 通过检索增强大模型生成"),
        Document("d3", "今天天气晴朗适合户外活动"),
        Document("d4", "智能体可以自己做决定"),
    ]

    # 复用一个简单的哈希向量器
    import hashlib

    def embed(text: str, dim: int = 64) -> list[float]:
        vec = [0.0] * dim
        for tok in tokenize(text):
            h = int(hashlib.md5(tok.encode()).hexdigest(), 16) % dim  # noqa: S324
            vec[h] += 1.0
        norm = math.sqrt(sum(v * v for v in vec))
        return [v / norm for v in vec] if norm > 0 else vec

    query = "智能体自己做决定"
    print(f"查询: {query}\n")
    for doc_id, score in hybrid_search(query, docs, embed, top_k=3):
        text = next(d.text for d in docs if d.id == doc_id)
        print(f"  {doc_id}  rrf={score:.4f}  {text}")


if __name__ == "__main__":
    main()