解析 ‘Hybrid Search’ 的数学原理:如何利用倒排索引(BM25)与向量索引(HNSW)的加权融合对抗幻觉?

各位同学,下午好。今天我们来探讨一个在现代信息检索与生成式人工智能领域至关重要的主题:混合搜索(Hybrid Search)的数学原理,以及它如何通过倒排索引(BM25)与向量索引(HNSW)的加权融合,有效对抗大型语言模型(LLM)的“幻觉”现象。

随着人工智能技术的飞速发展,尤其是以LLM为核心的生成式AI,我们正步入一个信息爆炸与知识重构的时代。然而,LLM的强大能力也伴随着一个显著的挑战:生成性幻觉(Hallucination)。这种现象指的是LLM在生成内容时,会创造出听起来合理但实际上与事实不符或在源文档中找不到的信息。为了构建更可靠、更值得信赖的AI系统,尤其是在检索增强生成(RAG)架构中,精确且全面的信息检索变得前所未有的重要。

传统的关键词搜索(如基于倒排索引)和新兴的语义搜索(如基于向量索引)各有优劣。关键词搜索擅长精确匹配和事实性检索,但缺乏对语义的理解;语义搜索则能捕捉深层语义,处理同义词和上下文,却可能因过于泛化而偏离核心事实。混合搜索正是为了融合这两种范式,取长补短,提供一个既能保证相关性又能兼顾准确性的强大检索机制。

一、倒排索引与BM25:传统检索的基石

我们首先从传统的信息检索开始。倒排索引(Inverted Index)是几乎所有搜索引擎的核心。它将文档中的每个词项映射到包含该词项的文档列表,以及该词项在文档中的位置信息。这使得搜索引擎能够快速定位包含特定关键词的文档。

在倒排索引之上,我们需要一个评分函数来衡量文档与查询的相关性。BM25(Okapi BM25)是目前最流行且非常有效的基于统计的词项权重模型之一。BM25并非一个简单的算法,而是一个函数族,其核心思想是根据词频(Term Frequency, TF)、逆文档频率(Inverse Document Frequency, IDF)和文档长度来计算文档与查询的相关性得分。

1.1 BM25的数学原理拆解

BM25的相关性评分公式如下所示:

$$ text{Score}(D, Q) = sum_{i=1}^{n} text{IDF}(q_i) cdot frac{text{TF}(q_i, D) cdot (k_1 + 1)}{text{TF}(q_i, D) + k_1 cdot left(1 – b + b cdot frac{|D|}{text{avgdl}}right)} $$

其中:

  • $D$ 代表一个文档。
  • $Q$ 代表一个查询,由词项 $q_1, q_2, ldots, q_n$ 组成。
  • $text{TF}(q_i, D)$ 是词项 $q_i$ 在文档 $D$ 中的词频(Term Frequency),即 $q_i$ 在 $D$ 中出现的次数。
  • $|D|$ 是文档 $D$ 的长度(通常是词的数量)。
  • $text{avgdl}$ 是语料库中所有文档的平均长度。
  • $k_1$ 和 $b$ 是BM25的自由参数,用于调节TF饱和度和文档长度归一化。

我们来详细剖析公式的各个部分:

a. 逆文档频率(IDF)

$$ text{IDF}(q_i) = lnleft(frac{N – n(q_i) + 0.5}{n(q_i) + 0.5} + 1right) $$

  • $N$ 是语料库中的总文档数。
  • $n(q_i)$ 是包含词项 $q_i$ 的文档数(Document Frequency)。

IDF衡量了一个词项的稀有程度。一个词项在越少的文档中出现,其IDF值越高,表明它对区分文档的贡献越大。相反,停用词(如“的”、“是”)在大量文档中出现,其IDF值会非常低,甚至接近于零,从而降低它们对相关性评分的影响。BM25的IDF公式中加入了0.5是为了避免分母为零或分子为零的情况,增强数值稳定性。

b. 词频(TF)饱和度与 $k_1$ 参数

$$ frac{text{TF}(q_i, D) cdot (k_1 + 1)}{text{TF}(q_i, D) + k_1 cdot left(1 – b + b cdot frac{|D|}{text{avgdl}}right)} $$

这部分是词项 $q_i$ 在文档 $D$ 中的加权词频。

  • TF(qi, D) 是词频。直观上,一个词在文档中出现的次数越多,文档与该词就越相关。
  • 然而,相关性不是线性增长的。当一个词出现次数达到一定程度后,其额外出现对相关性的贡献会逐渐减小,这被称为“TF饱和度”。
  • 参数 $k_1$(通常取值范围在1.2到2.0之间)控制着TF的饱和速度。$k_1$ 越大,TF对得分的影响越大;$k_1$ 越小,TF越快达到饱和。当 $text{TF}(q_i, D)$ 远大于 $k_1$ 时,这部分值趋近于 $(k_1+1)$,实现饱和。

c. 文档长度归一化与 $b$ 参数

  • |D| / avgdl 是文档长度与平均文档长度的比值。长文档通常比短文档更容易出现某个查询词,这可能导致长文档获得不公平的高分。
  • 文档长度归一化是为了抵消这种偏向。
  • 参数 $b$(通常取值范围在0到1之间)控制文档长度归一化的强度。
    • 当 $b=0$ 时,完全不进行长度归一化。
    • 当 $b=1$ 时,长度归一化效果最强。
  • 表达式 $1 – b + b cdot frac{|D|}{text{avgdl}}$ 称为“长度因子”(length factor)。如果一个文档比平均文档长,其长度因子会大于1,从而降低其词频对总分的影响;反之,如果文档短于平均长度,长度因子会小于1,提升其词频的影响。

通过这三个组件的精妙结合,BM25能够有效地评估文档与查询之间的相关性,尤其擅长处理关键词匹配和区分文档。

1.2 BM25的优势与局限

优势:

  • 精确匹配能力强: 对于精确的关键词匹配,BM25能提供非常高的召回率和准确率。
  • 计算效率高: 基于倒排索引,查询速度快,尤其适用于大规模文本数据。
  • 对稀有词敏感: 通过IDF机制,能有效识别和加权那些在语料库中不常见的、具有高区分度的词。
  • 无需训练: 参数 $k_1$ 和 $b$ 通常可以通过经验值或少量调优获得,不需要复杂的模型训练。

局限:

  • 语义理解能力弱: BM25是基于词项匹配的“词袋”(Bag-of-Words)模型,不理解词语的深层含义、同义词、反义词或上下文关系。例如,“汽车”和“车辆”对BM25来说是两个完全不同的词。
  • 对措辞敏感: 查询词的微小变化(如单复数、时态、词序)都可能导致结果大相径庭。
  • 无法处理词汇鸿沟: 当查询和文档使用不同的词汇表达相同概念时,BM25可能无法找到相关文档。
  • 无法捕捉意图: 对于用户查询背后的真实意图,BM25无法进行推断。

1.3 BM25的Python实现示例

为了更好地理解,我们来看一个简化的BM25计算示例。

import math
from collections import Counter

class BM25:
    def __init__(self, corpus, k1=1.5, b=0.75):
        self.corpus = corpus
        self.k1 = k1
        self.b = b
        self.N = len(corpus)
        self.doc_len = [len(doc) for doc in corpus]
        self.avgdl = sum(self.doc_len) / self.N
        self.df = self._calculate_df()
        self.idf = self._calculate_idf()

    def _calculate_df(self):
        """计算每个词项的文档频率 (Document Frequency)"""
        df = Counter()
        for doc in self.corpus:
            for word in set(doc): # 使用set避免重复计数同一个文档中的相同词
                df[word] += 1
        return df

    def _calculate_idf(self):
        """计算每个词项的逆文档频率 (Inverse Document Frequency)"""
        idf = {}
        for word, freq in self.df.items():
            idf[word] = math.log((self.N - freq + 0.5) / (freq + 0.5) + 1)
        return idf

    def _get_tf(self, word, doc):
        """计算词项在文档中的词频 (Term Frequency)"""
        return Counter(doc).get(word, 0)

    def get_score(self, query, doc_idx):
        """计算文档与查询的BM25分数"""
        score = 0
        doc = self.corpus[doc_idx]
        doc_length = self.doc_len[doc_idx]

        for query_term in query:
            if query_term not in self.idf:
                continue # 查询词不在语料库中,IDF为0,跳过

            tf = self._get_tf(query_term, doc)

            # BM25公式的核心部分
            numerator = self.idf[query_term] * tf * (self.k1 + 1)
            denominator = tf + self.k1 * (1 - self.b + self.b * (doc_length / self.avgdl))

            score += numerator / denominator
        return score

    def search(self, query, top_n=5):
        """对整个语料库进行BM25搜索"""
        query_tokens = query.lower().split() # 简单分词和小写化

        scores = []
        for i, doc in enumerate(self.corpus):
            score = self.get_score(query_tokens, i)
            scores.append((score, i, " ".join(doc)))

        scores.sort(key=lambda x: x[0], reverse=True)
        return scores[:top_n]

# 示例用法
documents = [
    "The quick brown fox jumps over the lazy dog".split(),
    "A dog is a man's best friend".split(),
    "Foxes are known for their cunning".split(),
    "The brown bear is a large mammal".split(),
    "A quick brown dog ran past the fox".split()
]

bm25_model = BM25(documents)

query = "brown fox quick"
results = bm25_model.search(query)

print(f"Query: '{query}'")
for score, doc_idx, doc_text in results:
    print(f"  Score: {score:.4f}, Doc Index: {doc_idx}, Doc: '{doc_text}'")

query_semantic = "canine companion"
results_semantic = bm25_model.search(query_semantic)
print(f"nQuery (semantic): '{query_semantic}'")
for score, doc_idx, doc_text in results_semantic:
    print(f"  Score: {score:.4f}, Doc Index: {doc_idx}, Doc: '{doc_text}'")

从上述示例中,我们可以看到对于精确匹配的查询 brown fox quick,BM25能够给出合理的结果。但对于语义相近但词汇不匹配的查询 canine companion(意为“犬类伴侣”),由于语料中没有直接出现这些词,BM25得分会很低,甚至为零,无法发现与“dog”(狗)相关的文档。这正是BM25的局限性所在。

二、向量索引与HNSW:语义检索的利器

为了弥补BM25在语义理解上的不足,向量索引应运而生。其核心思想是将文档和查询转换为高维向量(称为嵌入,Embeddings),这些向量在语义上相似的文本在向量空间中距离也更近。

2.1 嵌入(Embeddings)的生成

嵌入通常通过预训练的深度学习模型(如BERT、Word2Vec、Sentence-BERT等)生成。这些模型将文本(词、短语、句子或整个文档)映射到一个固定维度的实数向量空间。例如,dogcanine 的嵌入向量在向量空间中会非常接近,而 dogcar 的嵌入向量则会相距较远。

文本 -> 嵌入模型 -> 向量

2.2 近似最近邻(ANN)搜索与HNSW

在高维向量空间中,寻找与查询向量最相似的文档向量是一个计算密集型任务。精确的最近邻(Nearest Neighbor, NN)搜索需要计算查询向量与所有文档向量之间的距离,这在数据量大时是不可行的。因此,我们转向近似最近邻(Approximate Nearest Neighbor, ANN)搜索。

分层可导航小世界图(Hierarchical Navigable Small World, HNSW)是目前最先进、最流行的ANN算法之一。它通过构建一个多层图结构来加速搜索过程。

HNSW的数学与结构原理:

HNSW的核心思想是创建一个分层的图结构,其中:

  • 多层图: 索引由多个层(level)组成。最顶层(level L)包含最少的节点,节点之间的连接稀疏,覆盖较长的距离。最底层(level 0)包含所有数据点,节点之间的连接密集,覆盖较短的距离。
  • 小世界图: 在每一层,节点(向量)与其“邻居”相连。这种连接策略旨在创建一种“小世界”效应,即任意两个节点之间可以通过少数几步连接起来。
  • 跳跃连接(Skip Connections): 顶层的节点可以看作是底层节点的“跳跃连接”,允许搜索过程快速跳过大的区域,从而加速全局搜索。

HNSW的构建过程(插入一个新向量):

  1. 随机分配层级: 为新插入的向量随机分配一个最大层级 $L_{new}$。这个层级通常根据一个指数分布概率函数确定,例如 $P(l) = e^{-lambda(l-1)}$,其中 $lambda$ 是一个参数。这意味着大多数节点会被分配到较低的层级,而只有少数节点会出现在较高层级。
  2. 从最高层开始搜索: 从 HNSW 索引的最高层 $L{max}$ 开始,向下逐层搜索。在每一层 $l$,算法会找到离新向量最近的 $M{max}$ 个邻居。
  3. 贪婪搜索: 在每一层,算法会从一个入口点(通常是前一层找到的最近邻)开始,进行贪婪搜索。它会检查当前节点的邻居,并选择离查询向量最近的邻居作为下一个访问节点,直到找不到更近的邻居为止。这个过程类似于在图上“爬山”。
  4. 建立连接: 一旦找到当前层级下的最佳邻居,新向量就会与这些邻居建立连接。连接的数量由参数 $M$ 控制,即每个节点在每一层连接的最大邻居数量。为了保持图的效率,可能会使用启发式方法(如选择互惠最近邻)来选择最佳的 $M$ 个邻居。
  5. 向下层级重复: 这个过程会持续到 $L{new}$ 层。在 $L{new}$ 层及以下的所有层中,新向量都会被插入并连接到其最近邻。

HNSW的搜索过程:

  1. 选择入口点: 通常从最高层 $L_{max}$ 的一个预定义入口点(例如,随机选择的或预先存储的最佳入口点)开始。
  2. 层级遍历: 从最高层 $L_{max}$ 向下遍历。在每一层 $l$:
    • 执行贪婪搜索,找到距离查询向量最近的 $efSearch$ 个邻居(efSearch 是一个参数,控制搜索的宽度)。
    • 将这些邻居作为下一层搜索的入口点。
  3. 底层精炼: 在最底层(level 0),算法会执行一个更彻底的搜索,利用在上一层找到的 $efSearch$ 个入口点。它会维护一个大小为 $efSearch$ 的优先队列,存储目前找到的最近邻。通过探索这些最近邻的邻居,并更新优先队列,直到达到停止条件(例如,队列中的所有候选点都被探索过,或者已经迭代了足够多的步数)。

HNSW的关键参数:

  • M (Max Neighbors): 每个节点在创建时在其层中连接的最大邻居数。通常在10到64之间。更大的 $M$ 值会增加索引构建时间、索引大小和内存使用,但通常会提高搜索精度。
  • efConstruction (Construction Search Scope): 索引构建时,在每次插入新元素时,用于搜索其最近邻的候选邻居列表的大小。通常是 $M$ 的几倍,例如100到500。更大的 efConstruction 会提高索引的质量和搜索精度,但会显著增加构建时间。
  • efSearch (Search Scope): 在搜索阶段,用于搜索最近邻的候选邻居列表的大小。通常与 efConstruction 类似,可以动态调整。更大的 efSearch 会提高搜索精度,但会增加搜索时间。

2.3 HNSW的优势与局限

优势:

  • 语义理解强: 能够捕捉词语、句子甚至文档的深层语义,处理同义词、多义词和上下文。
  • 对措辞不敏感: 即使查询与文档使用不同的表达方式,只要语义相似,也能找到相关结果。
  • 高效的近似搜索: 在大规模数据集上,HNSW能够以接近对数时间复杂度进行搜索,远快于精确搜索。
  • 召回率高: 能够发现传统关键词搜索难以触及的、语义相关的文档。

局限:

  • 计算成本高: 生成高质量的嵌入向量通常需要强大的计算资源和时间。
  • 存储成本高: 向量索引通常比倒排索引占用更多内存,尤其是在高维度向量的情况下。
  • 对嵌入模型质量敏感: 搜索效果高度依赖于所使用的嵌入模型的质量。如果模型不佳,生成的向量可能无法准确反映语义关系。
  • “维度灾难”: 尽管HNSW在处理高维数据方面表现出色,但维度过高仍可能影响搜索效率和精度。
  • 可能引入“语义漂移”或“幻觉”: 有时,语义上相似的文档在事实上可能存在细微但关键的差异。纯语义搜索可能检索到与查询意图高度相似但在关键事实点上偏差的文档,这正是导致LLM幻觉的潜在原因之一。例如,搜索“关于月球着陆的阴谋论”,语义搜索可能会检索到大量关于“月球着陆”的文档,其中可能包含了实际着陆的详细信息,而非阴谋论本身。

2.4 HNSW的Python实现示例(概念性使用hnswlib

在实际应用中,我们通常会使用成熟的库,如hnswlib(Python)或Faiss(Meta)。这里我们展示如何概念性地使用hnswlib

import hnswlib
import numpy as np
from sentence_transformers import SentenceTransformer

# 1. 加载预训练的Sentence Transformer模型来生成嵌入
# 'all-MiniLM-L6-v2' 是一个轻量级且高效的模型
model = SentenceTransformer('all-MiniLM-L6-v2')

# 2. 准备语料库(与BM25示例相同,但这里需要原始字符串)
documents_raw = [
    "The quick brown fox jumps over the lazy dog",
    "A dog is a man's best friend",
    "Foxes are known for their cunning",
    "The brown bear is a large mammal",
    "A quick brown dog ran past the fox"
]

# 3. 生成文档的嵌入向量
document_embeddings = model.encode(documents_raw, convert_to_numpy=True)
num_dimensions = document_embeddings.shape[1]
num_elements = len(documents_raw)

# 4. 初始化HNSW索引
# space: 'l2' (欧式距离) 或 'ip' (内积) 或 'cosine' (余弦相似度)
# dim: 向量维度
p = hnswlib.Index(space='cosine', dim=num_dimensions)

# 5. 构建HNSW索引
# max_elements: 索引中最大元素数量
# ef_construction: 索引构建时的搜索参数
# M: 每个元素连接的邻居数量
p.init_index(max_elements=num_elements, ef_construction=200, M=16)
p.add_items(document_embeddings, np.arange(num_elements))
p.set_ef(50) # 设置搜索时的ef参数

# 6. 进行语义搜索
query_semantic = "canine companion"
query_embedding = model.encode([query_semantic], convert_to_numpy=True)

# k: 返回最近的k个结果
labels, distances = p.knn_query(query_embedding, k=num_elements) # 搜索所有文档

print(f"Query (semantic): '{query_semantic}'")
for i in range(num_elements):
    doc_idx = labels[0][i]
    # HNSWlib 返回的距离是与所选距离空间相关的,例如 'cosine' 空间下,距离越小表示越相似
    # 转换为相似度:1 - distance (如果距离为欧式距离或类似) 或直接使用内积
    # 对于cosine距离,hnswlib返回的是 (1 - cosine_similarity)。所以相似度是 1 - distance
    similarity = 1 - distances[0][i] 
    print(f"  Similarity: {similarity:.4f}, Doc Index: {doc_idx}, Doc: '{documents_raw[doc_idx]}'")

query_exact = "brown fox quick"
query_embedding_exact = model.encode([query_exact], convert_to_numpy=True)
labels_exact, distances_exact = p.knn_query(query_embedding_exact, k=num_elements)

print(f"nQuery: '{query_exact}'")
for i in range(num_elements):
    doc_idx = labels_exact[0][i]
    similarity = 1 - distances_exact[0][i]
    print(f"  Similarity: {similarity:.4f}, Doc Index: {doc_idx}, Doc: '{documents_raw[doc_idx]}'")

通过HNSW的示例,我们可以清晰地看到,对于 canine companion 这样的语义查询,HNSW能够识别出与 dog 相关的文档,并给出较高的相似度分数。这弥补了BM25的不足。

三、幻觉的挑战与混合搜索的必要性

现在,我们来到了问题的核心:为什么需要混合搜索来对抗幻觉?

大型语言模型(LLM)的幻觉现象并非偶然,它源于LLM的本质:它们是基于大规模文本数据进行训练的概率模型,旨在预测下一个最有可能的词。它们“理解”世界的方式是基于统计模式和语境关联,而非事实的存储与检索。当被要求生成基于特定信息的内容时,如果输入信息不足、模糊或存在歧义,LLM可能会“编造”出听起来合理但实际上错误的细节。

在检索增强生成(RAG)系统中,我们通过从外部知识库检索相关文档来为LLM提供上下文,以减少幻觉。然而,如果检索到的文档本身质量不高,或者未能精确回答查询,幻觉仍然可能发生。

  • BM25的局限如何导致幻觉?

    • 过于僵化: 如果用户查询使用了与知识库中文档不完全匹配的措辞,BM25可能无法检索到任何相关文档。当LLM接收到空或不相关的上下文时,它会倾向于依赖其内部的、可能过时或不准确的知识,从而产生幻觉。
    • 语义鸿沟: 用户可能使用高度抽象或隐喻的查询,BM25无法理解其深层意图,导致检索失败。
  • HNSW(纯语义搜索)的局限如何导致幻觉?

    • 语义漂移/过度泛化: HNSW擅长寻找语义相似的文档。但“语义相似”并不总是等于“事实准确”。例如,用户查询“治疗癌症的最新突破”,HNSW可能会检索到大量关于“癌症研究”、“药物开发”甚至“替代疗法”的文档。这些文档可能在语义上都与“癌症”和“治疗”相关,但其中可能不包含任何真正的“突破”,或者包含了未经证实的信息。LLM在阅读这些泛泛的或甚至误导性的文档后,可能会从中抽取一些看似合理但未经证实的片段,并将其整合到回答中,从而产生幻觉。
    • 缺乏关键词锚定: 在某些情况下,查询中的特定关键词具有决定性的事实意义。纯语义搜索可能会忽略这些关键词的精确匹配,而倾向于整体语义相似性,导致关键信息缺失。例如,用户查询“2023年诺贝尔物理学奖得主是谁?”,语义搜索可能会找到大量关于“诺贝尔奖”、“物理学”的文档,甚至可能找到2022年或2024年的预测,但如果文档中没有精确匹配“2023年诺贝尔物理学奖”的关键词,它可能无法优先检索到包含确切信息的文档。

混合搜索的必要性在于,它能够结合BM25的精确性和HNSW的泛化性,从而提供一个更为鲁棒和准确的检索结果集,减少LLM在生成过程中“脑补”的几率。 BM25确保了对关键词的精确锚定和事实性召回,而HNSW则扩展了语义覆盖,处理了同义词和上下文。两者协同工作,使得LLM能够接收到更全面、更精确、更少歧义的上下文信息,从而有效抑制幻觉。

四、加权融合的数学原理

将BM25和HNSW的搜索结果进行有效融合是混合搜索的核心。由于两种算法的评分机制和分数范围截然不同,直接相加是不可行的。我们需要对它们的分数进行标准化,然后进行加权融合。

4.1 评分标准化(Score Normalization)

标准化是将不同量纲的数值转换到统一的量纲或范围,以便进行比较和融合。

a. 最小-最大标准化(Min-Max Scaling)

将分数线性缩放到一个固定区间,通常是 $[0, 1]$。

$$ text{Score}{text{norm}} = frac{text{Score} – text{Score}{text{min}}}{text{Score}{text{max}} – text{Score}{text{min}}} $$

  • $text{Score}{text{min}}$ 和 $text{Score}{text{max}}$ 分别是该评分函数在所有检索结果中的最小值和最大值。
  • 优点: 简单直观,将所有分数映射到固定范围。
  • 缺点: 对异常值非常敏感。如果有一个极高的分数或极低的分数,会导致大部分数据点被压缩在一个很小的范围内,失去区分度。

b. Z-score标准化(Standardization)

将分数转换为均值为0、标准差为1的分布。

$$ text{Score}_{text{norm}} = frac{text{Score} – mu}{sigma} $$

  • $mu$ 是所有检索结果分数的均值。
  • $sigma$ 是所有检索结果分数的标准差。
  • 优点: 对异常值不敏感,保留了数据的分布形态。
  • 缺点: 转换后的分数没有固定范围,可能难以直观理解和设定权重。

c. 倒数排名融合(Reciprocal Rank Fusion, RRF)

RRF 是一种非常流行且鲁棒的融合方法,它不直接操作分数,而是操作排名。RRF的优点在于它对不同检索方法的具体评分函数不敏感,只需要知道每个文档的排名。

对于一个文档 $d$,其RRF分数计算如下:

$$ text{RRFScore}(d) = sum{r in text{Rankings}} frac{1}{k + text{rank}_r(d)} $$

  • $k$ 是一个平滑常数,通常设置为60(经验值),以降低高排名差异对总分的影响。
  • $text{rank}_r(d)$ 是文档 $d$ 在第 $r$ 个检索方法(例如BM25或HNSW)中的排名(从1开始)。如果文档未出现在某个检索方法的Top-N结果中,通常将其排名视为无穷大,即不贡献分数。
  • 优点:
    • 对分数不敏感: 不需要对原始分数进行标准化,避免了因分数分布差异带来的问题。
    • 鲁棒性强: 对单个检索方法的微小排名变化不敏感,即使某个方法给出了异常高的分数,只要其排名不高,也不会主导最终结果。
    • 易于实现: 仅需要排名信息。
    • 有效处理召回: 即使一个文档只在一个检索方法中排名很高,也能得到不错的RRF分数。
  • 缺点: 无法利用原始分数中可能包含的更精细的相关性信息,只关注排名。

d. Softmax归一化

将分数转换为概率分布,所有文档的概率之和为1。

$$ text{Score}_{text{norm}, i} = frac{e^{text{Score}i / T}}{sum{j=1}^{N} e^{text{Score}_j / T}} $$

  • $T$ 是温度参数,控制分布的尖锐程度。$T$ 越大,分布越平滑;$T$ 越小,分布越集中于最高分。
  • 优点: 给出概率解释,适合需要概率输出的场景。
  • 缺点: 同样对异常值敏感,且需要谨慎选择温度参数。

在实践中,RRF因其鲁棒性和实现简便性而广受欢迎。如果需要更精细地利用分数信息,Min-Max或Z-score可以与加权求和结合使用。

4.2 加权融合(Weighted Sum Fusion)

在分数标准化之后,我们可以采用加权求和的方式将两种方法的得分结合起来。

假设我们已经将BM25分数和HNSW相似度分数标准化到 $[0, 1]$ 区间。对于每个文档 $d$,其最终的融合分数 $text{Score}_{text{final}}(d)$ 可以表示为:

$$ text{Score}_{text{final}}(d) = w1 cdot text{Score}{text{BM25_norm}}(d) + w2 cdot text{Score}{text{HNSW_norm}}(d) $$

  • $w_1$ 是BM25分数的权重。
  • $w_2$ 是HNSW分数的权重。
  • 通常要求 $w_1 + w_2 = 1$,以保持总分在合理范围。
  • 权重选择: 权重的选择是混合搜索中的一个关键优化点。
    • 经验法: 根据领域知识和对两种方法优劣的理解,设定初始权重。例如,如果对关键词精确匹配要求更高,可以设置 $w_1 > w_2$。
    • A/B测试: 在实际用户环境中进行测试,比较不同权重组合下的用户满意度、点击率等指标。
    • 离线评估: 使用标注数据集,通过评估指标(如MRR, NDCG, MAP)来优化权重。
    • 机器学习: 训练一个排序模型(Learning-to-Rank),以文档的BM25分数、HNSW分数以及其他特征作为输入,预测文档的相关性。

数学上的融合哲学:

加权融合可以从信息论和贝叶斯推理的角度来理解。我们可以将每种检索方法看作是一个独立的“信息源”,它们各自提供了关于文档相关性的“证据”。融合的目标是结合这些证据,形成一个更全面、更可靠的判断。

当两种方法都高度同意某个文档的相关性时(例如,BM25分数高,HNSW分数也高),其融合分数会非常高。当它们意见不一致时(例如,BM25分数高但HNSW分数低,或者反之),融合分数会进行权衡。这正是对抗幻觉的关键:

  • 精确锚定: 如果查询中包含的某个关键词在文档中精确匹配(BM25高分),即使语义上略有偏移(HNSW分数中等),通过赋予BM25足够权重,该文档仍能被高亮。这确保了事实的准确性。
  • 语义扩展: 如果查询是高度语义化的,关键词不精确匹配,但语义上高度相关(HNSW高分),通过赋予HNSW足够权重,仍能召回相关文档。这扩展了召回范围。

通过调整权重,我们可以在精确性(由BM25贡献)和召回率/语义理解(由HNSW贡献)之间找到一个最佳平衡点,从而提供给LLM一个既有事实基础又有语义广度的上下文。

4.3 融合的Python实现示例

import numpy as np
from sklearn.preprocessing import MinMaxScaler
import math

# 假设我们有BM25和HNSW的搜索结果
# 每个结果是一个元组:(score, doc_index)
bm25_results = [
    (15.2, 0), # doc_idx 0
    (10.1, 4), # doc_idx 4
    (8.5, 2),  # doc_idx 2
    (3.1, 3),  # doc_idx 3
    (0.8, 1)   # doc_idx 1
]

hnsw_results = [
    (0.95, 1), # doc_idx 1
    (0.88, 0), # doc_idx 0
    (0.75, 4), # doc_idx 4
    (0.60, 3), # doc_idx 3
    (0.40, 2)  # doc_idx 2
]

# 假设总共有5个文档
all_doc_indices = {0, 1, 2, 3, 4}

# 1. 准备数据结构,确保所有文档都有分数(未找到的文档分数为0)
def prepare_scores(results, all_indices):
    score_map = {doc_idx: score for score, doc_idx in results}
    full_scores = [score_map.get(idx, 0.0) for idx in sorted(list(all_indices))]
    return np.array(full_scores)

bm25_scores_raw = prepare_scores(bm25_results, all_doc_indices)
hnsw_scores_raw = prepare_scores(hnsw_results, all_doc_indices)

print("原始BM25分数:", bm25_scores_raw)
print("原始HNSW分数:", hnsw_scores_raw)

# 2. 评分标准化 (Min-Max Scaling)
# 注意:MinMaxScaler需要2D数组,所以需要reshape
scaler_bm25 = MinMaxScaler()
bm25_scores_norm = scaler_bm25.fit_transform(bm25_scores_raw.reshape(-1, 1)).flatten()

scaler_hnsw = MinMaxScaler()
hnsw_scores_norm = scaler_hnsw.fit_transform(hnsw_scores_raw.reshape(-1, 1)).flatten()

print("nMin-Max标准化后的BM25分数:", bm25_scores_norm)
print("Min-Max标准化后的HNSW分数:", hnsw_scores_norm)

# 3. 加权融合
# 设定权重,例如,BM25权重0.4,HNSW权重0.6
w_bm25 = 0.4
w_hnsw = 0.6

# 确保权重之和为1
assert w_bm25 + w_hnsw == 1.0

final_scores_weighted_sum = w_bm25 * bm25_scores_norm + w_hnsw * hnsw_scores_norm

# 4. RRF融合 (Reciprocal Rank Fusion)
def calculate_rrf_scores(retrieval_results, all_indices, k=60):
    rrf_scores = {idx: 0.0 for idx in all_indices}

    # 将结果转换为排名字典
    # 排名从1开始
    rank_map = {}
    for system_id, results in enumerate(retrieval_results):
        sorted_results = sorted(results, key=lambda x: x[0], reverse=True)
        rank_map[system_id] = {}
        for rank, (score, doc_idx) in enumerate(sorted_results):
            rank_map[system_id][doc_idx] = rank + 1 # 排名从1开始

    for doc_idx in all_indices:
        for system_id in rank_map:
            rank = rank_map[system_id].get(doc_idx, float('inf')) # 未找到则视为无穷大排名
            if rank != float('inf'):
                rrf_scores[doc_idx] += 1 / (k + rank)

    # 将字典转换为数组并按文档索引排序
    sorted_rrf_scores = [rrf_scores[idx] for idx in sorted(list(all_indices))]
    return np.array(sorted_rrf_scores)

# 传入原始结果(包含分数和文档索引)
all_retrieval_results = [bm25_results, hnsw_results]
final_scores_rrf = calculate_rrf_scores(all_retrieval_results, all_doc_indices)

print("n加权和融合最终分数:", final_scores_weighted_sum)
print("RRF融合最终分数:", final_scores_rrf)

# 5. 最终排名
print("n--- 加权和融合排名 ---")
ranked_docs_weighted_sum = sorted(enumerate(final_scores_weighted_sum), key=lambda x: x[1], reverse=True)
for rank, (doc_idx, score) in enumerate(ranked_docs_weighted_sum):
    print(f"  Rank {rank+1}: Doc Index {doc_idx}, Score: {score:.4f}")

print("n--- RRF融合排名 ---")
ranked_docs_rrf = sorted(enumerate(final_scores_rrf), key=lambda x: x[1], reverse=True)
for rank, (doc_idx, score) in enumerate(ranked_docs_rrf):
    print(f"  Rank {rank+1}: Doc Index {doc_idx}, Score: {score:.4f}")

从示例中我们可以看到,文档0在BM25中得分最高,在HNSW中也较高;文档1在HNSW中得分最高,在BM25中得分很低。通过加权融合,文档0和文档1都得到了较高的综合分数,体现了两种方法的协同作用。RRF则通过排名直接进行融合,避免了分数标准化的复杂性。

五、实践中的考量与最佳实践

将混合搜索应用于RAG系统是一个系统工程,涉及多方面考量。

5.1 混合搜索工作流

一个典型的混合搜索RAG工作流包括:

  1. 查询预处理: 清理、分词、词形还原等。
  2. BM25检索: 使用预处理后的查询,通过倒排索引执行BM25搜索,获取 Top-K1 个文档及其BM25分数。
  3. 向量嵌入生成: 将原始查询转换为向量嵌入。
  4. HNSW检索: 使用查询向量,通过HNSW向量索引执行语义搜索,获取 Top-K2 个文档及其相似度分数。
  5. 结果合并与去重: 将BM25和HNSW返回的文档列表合并,并去除重复文档。确保每个唯一的文档都有一个BM25分数和一个HNSW分数(未被某个方法召回的文档,其对应分数为0)。
  6. 分数标准化与融合: 对合并后的文档集,分别标准化BM25分数和HNSW分数,然后进行加权融合(如加权和或RRF),得到最终的混合分数。
  7. 最终排序: 根据融合后的分数对文档进行降序排序,选择 Top-N 个最相关的文档。
  8. LLM上下文构建: 将这 Top-N 个文档的内容作为上下文,与用户查询一起输入给LLM,进行生成。

5.2 挑战与解决方案

  • 权重优化: 如何选择最佳的 $w_1, w_2$?这通常需要通过离线评估指标(如MRR, NDCG@K, MAP)和在线A/B测试来迭代优化。在不同领域或不同查询类型下,最佳权重可能不同。
  • 延迟问题: 同时执行两种搜索可能会增加总延迟。
    • 并行执行: BM25和HNSW搜索可以并行执行。
    • 索引优化: 确保BM25和HNSW索引都经过了高效的优化。
    • 结果数量: 限制BM25和HNSW各自召回的文档数量(K1和K2),减少合并和融合的计算量。
  • 索引维护: 需要维护两个独立的索引(倒排索引和向量索引),这增加了存储和更新的复杂性。
    • 统一平台: 使用支持混合索引的平台,如Elasticsearch的混合检索功能,或专门的向量数据库(Pinecone, Weaviate, Milvus)可能集成关键词搜索。
  • embedding模型选择: 嵌入模型的质量直接影响HNSW的性能。需要选择与领域、语言和任务匹配的高质量预训练模型,或进行微调。

5.3 评估指标

为了衡量混合搜索的有效性,我们需要使用恰当的评估指标:

  • 召回率(Recall@K): 衡量真实相关文档被检索出来的比例。
  • 准确率(Precision@K): 衡量检索出来的文档中真实相关文档的比例。
  • 平均精度(Average Precision, AP): 考虑检索出的相关文档的排名。
  • 平均精度均值(Mean Average Precision, MAP): 对多个查询的AP取平均。
  • 归一化折扣累积增益(Normalized Discounted Cumulative Gain, NDCG@K): 考虑相关性等级和排名,对高相关性且排名靠前的文档给予更高的分数。
  • 平均倒数排名(Mean Reciprocal Rank, MRR): 对于只有一个正确答案的查询,衡量第一个正确答案的排名。
  • A/B测试: 最直接的用户体验评估方式,通过用户反馈(点击、停留时间、满意度)来评估不同混合策略的效果。

结语

混合搜索是当前信息检索领域应对复杂查询和对抗LLM幻觉的强大范式。通过BM25提供的精确关键词匹配和HNSW提供的深层语义理解的加权融合,我们能够构建出更为健壮、准确且召回率高的检索系统。这不仅提升了用户体验,更为RAG系统提供了高质量的上下文,从而显著降低了LLM生成不准确或虚假信息的风险。理解并掌握其背后的数学原理和实践技巧,是每一位致力于构建下一代智能系统的工程师和研究人员的必备技能。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注