稀疏向量检索流程
AI 大模型开发

稀疏向量检索流程

JACIN··35 分钟阅读

完整的中文 RAG 稀疏检索原型:先切块 → 分词 → 建 BM25 索引 → 查询匹配。本文通过一个可运行的 Python 示例,逐步拆解每个环节的原理与实现细节。


1. 整体流程概览#

整体流水线分为四步:

  1. 文档预处理(Chunking):将长文档切成适当长度的文本块(chunks),便于精准定位信息。
  2. 中文分词(Tokenization):使用 jieba 等分词工具将中文文本切成词语序列——中文没有天然的空格分隔,必须依赖分词器。
  3. 构建 BM25 索引:扫描所有 chunks,统计全局 IDF、每个 chunk 的词频(TF)和文档长度等统计量。
  4. 查询匹配与打分:对用户查询分词后,遍历所有 chunks 实时计算 BM25 分数,返回 Top-N 结果。

2. 完整代码实现#

python
import jieba
from rank_bm25 import BM25Okapi
from langchain_text_splitters import RecursiveCharacterTextSplitter
from collections import Counter

# ──────────────────────────────
# 1. 文档预处理 + 分词
# ──────────────────────────────
docs = [
    "苹果手机最新功能包括AI摄影和长续航。发布会上提到了端侧AI与影像算法升级。",
    "香蕉是一种热带水果,富含钾元素,适合作为日常补充能量的食物。",
    "iPhone 16 Pro Max评测:屏幕更亮,影像更强,续航也有提升。",
]

# 中文分词函数(jieba 默认精确模式)
def tokenize_cn(text):
    return [t.strip() for t in jieba.cut(text) if t.strip()]

# ──────────────────────────────
# 辅助函数:生成稀疏向量
# ──────────────────────────────
def bm25_doc_sparse_vector(bm25: BM25Okapi, doc_index: int) -> dict[str, float]:
    """为指定 chunk 生成 BM25 稀疏向量(每个 token → 权重)"""
    freqs = bm25.doc_freqs[doc_index]
    dl = bm25.doc_len[doc_index]
    denom_const = bm25.k1 * (1.0 - bm25.b + bm25.b * dl / bm25.avgdl)  # 长度归一化项
    vec: dict[str, float] = {}
    for token, tf in freqs.items():
        idf = bm25.idf.get(token)
        if idf is None:
            continue
        score = idf * (tf * (bm25.k1 + 1.0)) / (tf + denom_const)  # 经典 BM25 TF 饱和公式
        if score != 0.0:
            vec[token] = float(score)
    return vec

def bm25_query_sparse_vector(bm25: BM25Okapi, query_tokens: list[str]) -> dict[str, float]:
    """为查询生成稀疏向量(每个 token → IDF × query_tf)"""
    qtf = Counter(query_tokens)
    vec: dict[str, float] = {}
    for token, tf in qtf.items():
        idf = bm25.idf.get(token)
        if idf is None:
            continue
        vec[token] = float(idf * tf)
    return vec

def format_sparse_vector(vec: dict[str, float], vocab_index: dict[str, int], top_k: int = 20) -> list[tuple[int, float, str]]:
    """将稀疏向量格式化为 (vocab_id, weight, token) 三元组,按权重降序"""
    items = sorted(vec.items(), key=lambda x: x[1], reverse=True)[:top_k]
    return [(vocab_index[t], w, t) for t, w in items]

# ──────────────────────────────
# 分块(Chunking)
# ──────────────────────────────
splitter = RecursiveCharacterTextSplitter(
    chunk_size=60,
    chunk_overlap=12,
    separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""],
)
chunks = []
chunk_sources = []
for i, doc in enumerate(docs):
    for chunk in splitter.split_text(doc):
        if chunk.strip():
            chunks.append(chunk)
            chunk_sources.append(i)

# ──────────────────────────────
# 2. 构建 BM25 索引
# ──────────────────────────────
tokenized_chunks = [tokenize_cn(chunk) for chunk in chunks]
print(tokenized_chunks)
bm25 = BM25Okapi(tokenized_chunks)
vocab_index = {token: i for i, token in enumerate(sorted(bm25.idf.keys()))}

# ──────────────────────────────
# 3. 查询匹配
# ──────────────────────────────
query = "苹果手机最新功能"
tokenized_query = tokenize_cn(query)
scores = bm25.get_scores(tokenized_query)
top_chunks = bm25.get_top_n(tokenized_query, chunks, n=3)

print("Top chunks:", top_chunks)
print("Query sparse vector:", format_sparse_vector(bm25_query_sparse_vector(bm25, tokenized_query), vocab_index, top_k=50))
for i, chunk in enumerate(chunks):
    vec = bm25_doc_sparse_vector(bm25, i)
    print(f"Chunk[{i}] source_doc={chunk_sources[i]} score={float(scores[i]):.6f} text={chunk}")
    print("Sparse vector:", format_sparse_vector(vec, vocab_index, top_k=20))

3. 关键参数详解#

3.1 分块参数(RecursiveCharacterTextSplitter)#

  • chunk_size=60:每个 chunk 的目标长度上限(按字符数计算,不是按 token/词数)。越小 → chunk 越多、粒度越细,定位更精准但可能丢失上下文;越大 → chunk 越少、每块信息更完整但可能引入噪声。
  • chunk_overlap=12:相邻 chunk 之间重叠的字符数。用途是避免关键信息刚好切在边界,导致"上一块缺后半句、下一块缺前半句"。重叠越大 → 冗余越多、召回更稳但索引成本更高。
  • separators:递归分割的优先级列表。RecursiveCharacterTextSplitter 按列表顺序依次尝试用这些分隔符切割——优先在段落边界切(\n\n)、其次换行(\n)、再其次句号()、然后逗号()……尽量保持语义完整性。如果用当前分隔符切完某段仍超过 chunk_size,就递归地用下一个更细的分隔符继续切。

滑动窗口示意

假设文本长度 200,chunk_size=60chunk_overlap=12,则步长 = 60 − 12 = 48

Chunk起始位置结束位置覆盖范围
第 1 块0600 – 60
第 2 块4810848 – 108(与第 1 块重叠 12 字符)
第 3 块9615696 – 156(与第 2 块重叠 12 字符)
第 4 块144200144 – 200(最后一块可能不足 60)

边界附近的内容会在相邻两块中都出现一次,确保不丢失关键信息。

3.2 BM25 核心参数#

  • k1 = 1.5(默认值):控制词频饱和速度。k1 越大,TF 的增长越线性(高频词获得更高分数);k1 越小,TF 很快饱和(出现 1 次和出现 10 次差别不大)。
  • b = 0.75(默认值):控制文档长度归一化强度。b=1 时完全按文档长度归一化(长文档被严重惩罚);b=0 时完全不考虑长度。0.75 是一个经验性的平衡值。

4. 索引构建过程(BM25Okapi 内部)#

当执行 bm25 = BM25Okapi(tokenized_chunks) 时,内部会扫描所有 chunks,统计以下信息:

属性含义数据结构
bm25.idf全局逆文档频率字典:每个唯一 token 的 IDF 值dict[str, float]
bm25.doc_freqs每个文档的词频(TF)统计list[dict[str, int]]
bm25.doc_len每个文档的长度(token 数)list[int]
bm25.avgdl所有文档的平均长度float

5. IDF 详解:全局逆文档频率#

5.1 什么是 IDF?#

IDF(Inverse Document Frequency) 是 BM25 的核心组成部分,衡量一个词的"稀有程度"——越稀有的词,区分能力越强,IDF 越高。

5.2 IDF 计算公式#

BM25 Okapi 使用的 IDF 公式为:

IDF(t)=ln ⁣(Nn(t)+0.5n(t)+0.5)IDF(t) = \ln\!\left(\frac{N - n(t) + 0.5}{n(t) + 0.5}\right)

其中:

  • NN = 文档总数(本例中 N = 3)
  • n(t)n(t) = 包含 token tt 的文档数

5.3 IDF 值示例#

本例中 bm25.idf 的部分内容:

python
{'苹果': 0.5108, '手机': 0.5108, 'AI': 0.5108, '续航': 0.0989, '影像': 0.0989, '。': 0.0989, ...}
Token 类型出现文档数 nIDF 计算IDF 值含义
专有词(苹果、手机、AI …)1ln((3−1+0.5)/(1+0.5)) ≈ 0.51080.5108稀有 → 高权重,区分度强
常见词(续航、影像、。)2ln((3−2+0.5)/(2+0.5)) ≈ 0.09890.0989较常见 → 低权重
全出现词(如停用词"的")3ln((3−3+0.5)/(3+0.5)) ≈ −1.946负值几乎无贡献(rank_bm25 会截断为 0)

6. 稀疏向量表示#

6.1 文档稀疏向量#

bm25_doc_sparse_vector 返回的是 dict[str, float]——每个 token 映射到它在该 chunk 中的 BM25 贡献权重

计算公式(每个 token 的权重):

w(t,d)=IDF(t)×TF(t,d)×(k1+1)TF(t,d)+k1×(1b+b×davgdl)w(t, d) = IDF(t) \times \frac{TF(t,d) \times (k_1 + 1)}{TF(t,d) + k_1 \times \left(1 - b + b \times \frac{|d|}{avgdl}\right)}

其中:

  • TF(t,d)TF(t,d) = token tt 在文档 dd 中的出现次数
  • d|d| = 文档 dd 的长度(token 数)
  • avgdlavgdl = 平均文档长度
  • 分母中的 k1×(1b+b×d/avgdl)k_1 \times (1-b+b \times |d|/avgdl) 就是代码中的 denom_const,实现长度归一化

6.2 查询稀疏向量#

bm25_query_sparse_vector 的计算更简单:

wq(t)=IDF(t)×TFq(t)w_q(t) = IDF(t) \times TF_q(t)

查询通常很短,每个词出现一次,所以 TFqTF_q 通常为 1,权重就等于 IDF 值。查询侧没有长度归一化(查询长度固定,无需惩罚)。

6.3 稀疏向量的存储形式#

稀疏向量以 (index, value) 对的形式存储,即 (词汇 ID, 权重值)。只记录非零维度,这正是高效设计——假设词表大小 50000,一个 chunk 只有 20 个唯一词,则只存 20 个维度而非 50000 个,避免内存爆炸。


7. BM25 检索打分流程#

当执行 bm25.get_scores(tokenized_query) 时,内部流程如下:

7.1 查询预处理#

  • 对 query 分词得到 tokenized_query
  • 计算查询的稀疏向量:每个 query token 的权重 ≈ IDF(因为 query tf 通常为 1)

7.2 遍历所有 chunks 实时打分#

对每个 chunk ii,总分数的计算公式:

scorei=tqueryIDF(t)×TF(t,chunki)×(k1+1)TF(t,chunki)+k1×(1b+b×leniavgdl)score_i = \sum_{t \in query} IDF(t) \times \frac{TF(t, chunk_i) \times (k_1 + 1)}{TF(t, chunk_i) + k_1 \times \left(1 - b + b \times \frac{len_i}{avgdl}\right)}

关键细节:

  • 如果 chunk 中该 query token → 计算 BM25 子分数
  • 如果 chunk 中没有该 query token → 子分数 = 0(稀疏匹配的本质)
  • 所有 query token 的子分数求和 → 该 chunk 的总分数
  • 不是遍历所有 token 的 IDF(IDF 是全局共享的,查表即可),而是遍历所有 chunks 的 TF + 长度,结合全局 IDF 计算

7.3 排序返回#

  • scores:一个 numpy array,包含所有 chunks 的总分数
  • get_top_n:根据分数降序,取 Top-N 个 chunks(返回原文)

8. 运行输出与分析#

8.1 分词结果#

jsx
[
  ['苹果', '手机', '最新', '功能', '包括', 'AI', '摄影', '和', '长', '续航', '。', '发布会', '上', '提到', '了', '端侧', 'AI', '与', '影像', '算法', '升级', '。'],
  ['香蕉', '是', '一种', '热带', '水果', ',', '富含', '钾', '元素', ',', '适合', '作为', '日常', '补充', '能量', '的', '食物', '。'],
  ['iPhone', '16', 'Pro', 'Max', '评测', ':', '屏幕', '更亮', ',', '影像', '更强', ',', '续航', '也', '有', '提升', '。']
]

注意:本示例中因为原文档较短(均 < 60 字符),RecursiveCharacterTextSplitter 实际上没有进一步切块,每篇文档保持为一个 chunk。在实际生产中,文档通常更长,会产生多个 chunks。

8.2 检索结果#

查询 "苹果手机最新功能" 的 Top 3 结果:

排名Chunk来源文档BM25 得分匹配原因
🥇 1苹果手机最新功能包括AI摄影和长续航…doc[0]1.9078完全命中"苹果""手机""最新""功能"四个高 IDF 词
🥈 2iPhone 16 Pro Max评测…doc[2]0.0000无直接词汇匹配("iPhone" ≠ "苹果"——BM25 无法理解同义词)
🥉 3香蕉是一种热带水果…doc[1]0.0000完全无关

8.3 查询稀疏向量#

text
Query: [(38, 0.5108, '苹果'), (21, 0.5108, '手机'), (29, 0.5108, '最新'), (13, 0.5108, '功能')]

四个词的 IDF 都是 0.5108(各自只在 1 个文档中出现),权重相等。

8.4 文档稀疏向量(Chunk[0] 示例)#

text
Chunk[0]: [(AI, 0.6945), (苹果, 0.4769), (手机, 0.4769), (最新, 0.4769), ...]
  • "AI" 权重最高(0.6945):因为 "AI" 在此 chunk 中出现了 2 次(TF=2),IDF 也高,所以贡献最大
  • 其他词出现 1 次,权重相近(约 0.477)
  • "续航""影像" 等跨文档出现的词权重很低(约 0.092),因为 IDF 低

9. rank_bm25 vs 生产级方案#

维度rank_bm25(内存)Milvus / Elasticsearch BM25
检索机制遍历所有 chunks 实时计算倒排索引:先过滤包含 query token 的 chunks → 再打分
时间复杂度O(N),线性扫描全部 chunksO(k),k 为包含 query token 的 chunk 数(远小于 N)
万级 chunks 速度较慢(百毫秒级)快(毫秒级)
增量更新需重建整个索引支持实时增量写入
适用场景小规模测试 / 原型验证生产环境(推荐)

10. 传统 BM25 vs Learned Sparse(如 BGE-M3)#

维度传统 BM25(本文内容)Learned Sparse(BGE-M3、SPLADE 等)
权重来源统计规则(IDF + TF)神经网络端到端学习
语义理解❌ 纯词汇匹配,无法处理同义词✅ 能捕捉语义相似性(如"苹果"↔"iPhone")
可解释性✅ 强,每个词的贡献清晰可查⚠️ 较弱,权重由模型学习得到
训练成本无需训练需要预训练模型 + 可能需要微调
增量更新需重建索引 / 使用倒排索引向量数据库实时 upsert
推荐场景关键词精确匹配、专有名词敏感场景现代 Hybrid RAG(推荐,与稠密检索互补)

11. 总结与最佳实践#

评论

还没有评论,来发第一个吧