从「BM25 是什么」一路到「Milvus 稀疏/稠密混合检索、ES 分词器、倒排索引、词表维护」的完整学习笔记。适合做过关键词检索、想搞懂 BM25 在现代向量检索体系里位置的同学。

0. 一张图先建立全局认知

文本 ─①分词器(analyzer)──> 词项 ─②索引引擎──> 词表 + 倒排索引 ─③BM25──> 权重/分数
                              ↑ 谁负责什么 ↑
        分词=analyzer      词表/索引=Milvus/ES       打分=BM25(只是消费者)

稠密向量 = 模型编码(语义)     稀疏向量 = BM25/SPLADE 登记词项(词法)
Hybrid Search = 在「排名层」融合稀疏 + 稠密

记住这张图,后面的所有细节都是对它的展开。最关键的一点:BM25 只负责「打分」,不管分词、不管建索引、不管维护词表。

1. 认识 BM25

1.1 它是什么

BM25(Best Match 25)是信息检索领域最经典、应用最广的排序算法,是 TF-IDF 的改进版本,也是 Elasticsearch、Lucene、Solr 等搜索引擎的默认打分函数

1.2 打分公式

给定查询 $q$ 和文档 $d$:

$$\text{score}(d, q) = \sum_{t \in q} \text{IDF}(t) \cdot \frac{f(t, d) \cdot (k_1 + 1)}{f(t, d) + k_1 \cdot \left(1 - b + b \cdot \frac{|d|}{\text{avgdl}}\right)}$$

其中:

  • $f(t, d)$:词 $t$ 在文档 $d$ 中的词频
  • $|d|$:文档长度(词数)
  • $\text{avgdl}$:语料库平均文档长度
  • $k_1$:词频饱和参数(通常 1.2~2.0)
  • $b$:长度归一化参数(通常 0.75)
  • $\text{IDF}(t) = \ln\left(\frac{N - n(t) + 0.5}{n(t) + 0.5} + 1\right)$

1.3 相比 TF-IDF 的两大改进

  1. 词频饱和:TF-IDF 中词频线性增长,BM25 让词频增长趋于平缓,避免一个词重复出现就 dominate 分数。
  2. 文档长度归一化:长文档天然更容易命中词,BM25 通过 $b$ 参数对长文档做惩罚,更公平。

1.4 适用与局限

  • 适用:传统关键词检索、全文搜索、RAG 的稀疏检索 baseline、与稠密向量做 hybrid search。
  • 局限:纯词项匹配,不理解语义(「汽车」查不到「轿车」),所以现在常和稠密向量检索互补。

2. BM25 已经是关键词搜索了,为什么还要向量?

一句话:BM25 是「字面匹配」,稠密向量是「语义匹配」,两者解决的问题根本不同,需要互补。

2.1 BM25 的本质局限:只认字,不认意思

查询 文档里写的 BM25 能命中吗 原因
汽车价格 轿车报价 没有公共词项
如何退款 退货流程 同义但字面不同
数据库 database 中英文
ResNet ResNet 字面一致
errcode 500 errcode 500 精确词项

只要用户用的词和文档里的词不是同一串字符,BM25 就抓瞎。

2.2 反过来,向量也有 BM25 擅长的地方

  1. 精确专有名词 / 代码 / 编号:ResNet-50、CVE-2021-44228、errcode ECONNREFUSED——embedding 容易混到近似但不准的结果。
  2. 低频词、罕见术语:embedding 对训练语料里没见过的词编码不准。
  3. 可解释性:BM25 命中就是因为含某个词,可审计。

2.3 错误方向不同,所以 1+1 > 2

  • BM25 的错误:语义相关但字面不同 → 漏召回(false negative)
  • 向量的错误:字面相似但语义无关 → 误召回(false positive)

两类的错误方向不同,融合后各自补对方的盲区。这就是 RRF / 加权融合能在绝大多数 IR benchmark 上同时提升 recall 和 precision 的原因。

2.4 什么时候只用一个

  • 只用 BM25:法律条文、编号检索、代码符号、严格关键词过滤。
  • 只用向量:问答语义检索、自然语言找相似描述、跨语言。
  • 两者都要:通用文档搜索、RAG、客服知识库——绝大多数真实场景。

3. 单纯 BM25 怎么用

三种方式,从轻到重:

3.1 Python 库 rank_bm25(小规模 / 实验)

from rank_bm25 import BM25Okapi
import jieba

corpus = [
    "Milvus 是一个向量数据库",
    "Elasticsearch 支持全文检索",
    "向量数据库可以存储 embedding",
    "BM25 是经典的关键词打分算法",
]
tokenized_corpus = [list(jieba.cut(doc)) for doc in corpus]

bm25 = BM25Okapi(tokenized_corpus)             # 建索引
tokenized_query = list(jieba.cut("向量数据库"))
scores = bm25.get_scores(tokenized_query)      # 打分

top_k = sorted(zip(corpus, scores), key=lambda x: -x[1])[:3]
for doc, score in top_k:
    print(f"{score:.4f}\t{doc}")

rank_bm25 提供三个变体:BM25Okapi(最常用)、BM25L(对长文档友好)、BM25Plus(改进 IDF)。

注意:纯 Python、全量计算,适合几千到几万篇文档,上百万扛不住。

3.2 Elasticsearch / OpenSearch(生产级)

ES 的默认打分就是 BM25,建索引直接搜:

es.indices.create(index="docs", mappings={
    "properties": {"content": {"type": "text", "analyzer": "ik_max_word"}}
})
for doc in corpus:
    es.index(index="docs", document={"content": doc})
res = es.search(index="docs", query={"match": {"content": "向量数据库"}}, size=3)

3.3 在 Milvus 里只用 BM25(不混合稠密)

只建稀疏字段、不建稠密字段:

schema.add_field("text", DataType.VARCHAR, max_length=8192)
schema.add_field("sparse", DataType.SPARSE_FLOAT_VECTOR)
schema.add_function(Function(name="bm25", function_type=FunctionType.BM25,
                             input_field_names=["text"], output_field_names=["sparse"]))

3.4 怎么选

场景 推荐
原型、几百~几万文档、本地脚本 rank_bm25
生产、需要分词/高亮/过滤/运维成熟 Elasticsearch
已在用 Milvus、想统一存储检索 Milvus 内置 BM25
数据量大 + 要和向量混合 Milvus / ES

4. Milvus 里 BM25 + 语义怎么做(混合检索)

4.1 整体架构

        用户 query (文本)
            │
      ┌─────┴─────┐
      │           │
  原样送 BM25   embedding 模型 → 稠密向量
      │           │
  稀疏检索       稠密检索
 (SPARSE)      (HNSW/COSINE)
      │           │
      └─────┬─────┘
        RRF / 加权融合
            │
        最终 Top-K

4.2 完整代码

from pymilvus import (MilvusClient, DataType, Function, FunctionType,
                      AnnSearchRequest, RRFRanker)
from sentence_transformers import SentenceTransformer

client = MilvusClient("http://localhost:19530")
emb_model = SentenceTransformer("BAAI/bge-small-zh-v1.5")
DIM = 384
COLLECTION = "hybrid_demo"

# 1. schema:text + sparse + dense
schema = MilvusClient.create_schema(auto_id=True, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("text", DataType.VARCHAR, max_length=4096,
                 enable_analyzer=True, analyzer_params={"type": "chinese"})
schema.add_field("sparse", DataType.SPARSE_FLOAT_VECTOR)
schema.add_field("dense", DataType.FLOAT_VECTOR, dim=DIM)

# 2. BM25 函数:text -> sparse(写入时只给 text,自动算稀疏)
schema.add_function(Function(
    name="bm25", function_type=FunctionType.BM25,
    input_field_names=["text"], output_field_names=["sparse"],
))

# 3. 索引
index_params = client.prepare_index_params()
index_params.add_index(field_name="sparse", index_type="SPARSE_INVERTED_INDEX",
                       metric_type="IP", params={"drop_ratio_build": 0.2})
index_params.add_index(field_name="dense", index_type="HNSW",
                       metric_type="COSINE", params={"M": 16, "efConstruction": 200})
client.create_collection(COLLECTION, schema=schema, index_params=index_params)

# 4. 插入(只给 text + dense)
docs = ["Milvus 是开源向量数据库", "轿车报价多少"]
rows = [{"text": d, "dense": emb_model.encode(d).tolist()} for d in docs]
client.insert(COLLECTION, rows)

# 5. 混合检索
query = "汽车价格"
sparse_req = AnnSearchRequest(data=[query], anns_field="sparse",
                              param={"metric_type": "IP"}, limit=20)
dense_req = AnnSearchRequest(data=[emb_model.encode(query).tolist()],
                             anns_field="dense",
                             param={"metric_type": "COSINE"}, limit=20)
results = client.hybrid_search(COLLECTION, reqs=[sparse_req, dense_req],
                               ranker=RRFRanker(k=60), limit=5,
                               output_fields=["text"])

4.3 关键决策点

为什么稀疏用 IP,稠密用 COSINE?

  • BM25 稀疏向量本质是「词项→权重」,分数就是点积(内积)→ IP。
  • 稠密向量比较方向,归一化消除长度 → COSINE。
  • 两者量纲不同(BM25 几十,cosine 0~1),不能直接相加原始分数,这就是 ranker 存在的原因。

RRF vs WeightedRanker:

Ranker 原理 何时用
RRFRanker(k=60) 只看排名,分数 1/(k+rank) 求和 默认首选,无需调参
WeightedRanker(0.7, 0.3) 分数加权求和(需归一化) 清楚哪路更重要

经验:没把握用 RRF;有标注集再上 WeightedRanker 网格搜索。

5. BM25 怎么「算出」稀疏向量?(核心洞察)

这是最关键的概念点。BM25 不是 embedding——它没有神经网络、没有学习。它做的只是「把文本里每个词,按统计公式算一个权重,塞到这个词对应的维度上」。

5.1 一个完整算例

3 篇文档:doc1="向量 数据库 检索"doc2="向量 数据库 存储"doc3="数据库 检索 算法"

建词表(term → 维度 id):

词项 dim_id
向量 0
数据库 1
检索 2
存储 3
算法 4

算统计量:N=3,avgdl=3;df:向量=2、数据库=3、检索=2、存储=1、算法=1。

算 IDF(k1=1.2, b=0.75):

  • 向量 IDF ≈ 0.47
  • 数据库 IDF ≈ 0.134
  • 存储 IDF ≈ 0.847(越稀有 IDF 越大)

算 doc2 各词权重 → 组装稀疏向量:

doc2 稀疏向量 = {0: 0.47, 1: 0.134, 3: 0.847}   # 其它维度为 0,省略

5.2 查询打分 = 内积

query=“向量 检索” → 稀疏向量 {0:w, 2:w},对 doc1 做内积:

score(query, doc1) = w_q(向量)×w_doc1(向量) + w_q(检索)×w_doc1(检索)

BM25 打分 = query 稀疏向量 · 文档稀疏向量的内积——这就是为什么 Milvus 稀疏检索用 metric_type="IP"

5.3 和 embedding 的本质区别

BM25 稀疏向量 Embedding 稠密向量
怎么生成 统计公式 神经网络前向
维度含义 一个具体词项(可解释) 学到的抽象特征(不可解释)
维度数 词表大小(几十万~百万),极稀疏 固定(384/768),全稠密
是否学习 否,确定性
同义能力

心智模型:Embedding 是神经网络把语义编码进稠密向量;BM25 是统计公式把词项登记进稀疏向量。两者都叫「向量」,但一个是模型产出、一个是公式产出。Milvus 用「稀疏向量」这个统一抽象表示 BM25,是为了让稀疏检索和稠密检索共用同一套字段/索引/检索接口。

6. 稀疏向量的维度是多少?跟稠密一致吗?

不一致,差别很大。

维度 稠密向量 稀疏向量
维度长度 固定(如 768),建表指定 dim 不固定 = 词表大小,建表不指定 dim
单条非零值数 全部(768 个) 几十~几百(命中词数)
维度含义 学到的抽象特征 一个具体词项
维度来源 embedding 模型 语料的词(动态词表)
维度可变吗 是,随新词增长
存储 定长 dense 只存非零项

关键点:

  • Milvus 稀疏字段 schema 里没有 dim 参数
  • 稀疏维度 = 整个 collection 词表大小,词表是动态生成的。
  • 稀疏存储:只存非零项 {dim_id: weight},不存几百万个 0。所以词表巨大但单条占用反而小。
  • 稀疏和稠密是两套平行的向量空间,hybrid search 靠排名层融合,不需要维度对齐。

7. 词表是什么?怎么维护?

7.1 三个概念分清

概念 是什么 谁产生
词项 (term) 分词后的单个词 分词器 analyzer
词表 (vocabulary) 所有不同词项集合 + term→id 映射 索引引擎(Milvus/ES)
BM25 权重 每个词在每篇文档的分数 BM25 公式

词表不是 BM25 产生的,是分词器 + 索引引擎维护的;BM25 只是词表的消费者。 把 BM25 换成 SPLADE,词表概念依然存在;换分词器,词表完全变了。

7.2 词表长什么样

┌──────────┬────────┬─────────┐
│ term     │ dim_id │ df      │
├──────────┼────────┼─────────┤
│ 向量      │ 0      │ 2       │
│ 数据库    │ 1      │ 3       │
│ 检索      │ 2      │ 2       │
└──────────┴────────┴─────────┘

7.3 维护机制:segment 级增量

  • Growing segment:写入时分词,遇新词分配新 dim_id,统计量内存累加。
  • Sealed segment:seal 时词表固化进倒排索引字典。
  • Compaction:多段合并,词表合并、统计量重算。
  • 只增不减:删文档不回收 dim_id(避免全局 id 重排),要彻底收缩只能重建 collection。

7.4 词表和分词器强绑定

同一句「向量数据库」,不同分词器产出不同词表:

jieba:         ["向量", "数据库"]
IK ik_smart:   ["向量数据库"]
IK ik_max_word:["向量","数据库","数据","据库"]
standard:      ["向","量","数","据","库"]

含义:

  1. 分词器一旦确定不能随便换,换了必须重建索引。
  2. 新词问题:词典里没有的词切不出来,词表没正确 term,BM25 查不到——要靠自定义词典(IK 支持热更新)。
  3. 同义词不在词表管,是查询时扩展,和词表维护是两回事。

8. 倒排索引详解

8.1 为什么需要倒排索引

正排(文档→词)查「检索」在哪些文档,要遍历所有文档,O(N×文档长度)。 倒排(词→文档)直接取 检索 → [doc1, doc3],O(命中文档数),和总文档数无关。

8.2 完整结构

字典(Term Dictionary):term → 指向 posting list 的指针。实现方式:排序数组二分 / 哈希表 / FST(Lucene 用,前缀共享压缩,几百万词压到很小内存)。

倒排表(Posting List):每个词项对应的文档列表,带 doc_id、tf、positions、payload/权重。

Milvus 稀疏语境下,posting list 存的就是 (doc_id, BM25 权重),查询时直接做内积,不再现场算 tf/长度归一。

8.3 查询流程

query="向量 检索"
  ① 查字典 → 取两个 posting list
  ② 归并候选集 {doc1, doc2, doc3}
  ③ 对每个候选算 BM25 分数(取 posting list 里的 tf/权重)
  ④ 排序取 Top-K

效率核心:只碰了「向量」「检索」两个 posting list,没扫全量文档。复杂度 ≈ O(两个 posting list 长度之和),和语料规模几乎无关——这就是百万文档毫秒级返回的原因。

8.4 核心优化技术

  1. doc id 重映射 + 差值编码:存差值 [3,14,28,...] 而非绝对值,小数字更好压缩。
  2. 压缩编码:VByte/Varint、PFor Delta(Lucene 大量用),压缩到 1/4~1/8。省磁盘更省内存带宽。
  3. Skip List 跳表:长 posting list 做交集时跨段跳过,高频词尤其关键。
  4. 分块 + 按需加载:positions 占空间大,只在短语查询时加载。

8.5 倒排 vs 稠密图索引(HNSW)

倒排(稀疏/BM25) HNSW(稠密)
数据结构 词→文档列表 邻接图
精确性 精确 近似(ANN)
擅长 词项精确匹配 语义相似

8.6 Milvus 稀疏倒排索引的特殊之处

  1. 字典 key 是 dim id 而非字符串。
  2. posting list 存权重而非 tf(BM25 已算好)。
  3. drop_ratio_build 剪枝:建索引时丢每篇文档权重最低的比例,压缩+加速。
  4. 统一进向量检索抽象,对外接口和稠密一致,内部却是不同引擎。

9. 分词:BM25 效果的天花板

9.1 分词是什么

把连续文本切成词项(term/token)。计算机不懂「词」,检索要按词匹配,就必须先切。难点在中文(无空格、有歧义)。

经典歧义:「南京市长江大桥」→ 正确「南京市/长江大桥」vs 错误「南京/市长/江大桥」。

9.2 Analyzer 三层结构

原始文本
  ① Character Filter(去 HTML、正则清洗)
  ② Tokenizer(切词 —— 决定切法)
  ③ Token Filter(大小写、停用词、同义词、词干化)
最终词项流

调试神器:POST /_analyze { "analyzer": "ik_max_word", "text": "..." }

9.3 中文分词器对比

分词器 特点 用在哪
jieba 词典+HMM,轻量快 Python、Milvus 内置中文 analyzer
IK ES 插件最流行,双模式、热更新词典 Elasticsearch
Smartcn ES 自带,不如 IK 灵活 Elasticsearch
HanLP 最准最重 高精度场景

IK 两种模式(最实用配置):

  • ik_max_word:切到最细,索引端用(召回全)。
  • ik_smart:最粗切分,查询端用(精度高)。

索引细 + 查询粗是经典搭配:索引覆盖所有组合保证 recall,查询切成最完整词项避免噪声。

9.4 英文分词要点

  • 词干化(stemming):running→run、cars→car、databases→database。英文提升召回的关键。
  • 停用词、大小写归一、asciifolding(café→cafe)。
  • english analyzer 内置这套组合。

9.5 分词对检索的影响

query 文档 分词器 命中 原因
汽车价格 轿车报价 任何 词项不重叠,分词救不了
向量数据库 向量数据库 standard(逐字) 单字命中噪声大
向量数据库 向量数据库 ik_smart ✅精准 整词命中
databases database 无词干化 复数没归一
ChatGPT ChatGPT 很强 jieba 无新词词典 ❌/差 切成 Chat/GPT

9.6 核心原则

  1. 索引端和查询端必须同族分词器。
  2. 上线后不能随便换,换则重建。
  3. 中文优先上专门分词器,别用 standard 逐字。
  4. 新词多准备自定义词典,最好支持热更新。
  5. 分词质量是 BM25 的天花板——分词切错,BM25/倒排再优化也没用。

10. rank_bm25 是「打分器」不是「检索引擎」

10.1 BM25Okapi(tokenized_corpus) 干什么

用分好词的语料构建 BM25 索引:扫一遍语料,算出每个词的 df/IDF、每篇文档的长度/词频、avgdl,全部缓存进对象。之后 get_scores(query) 直接复用这些统计量打分。

对应关系:

rank_bm25 Milvus/ES 对应
tokenized_corpus 分词器产出的词项流
BM25Okapi(…) 构造 建 SPARSE_INVERTED_INDEX
对象内部统计量 词表统计 + 倒排表 tf/权重
get_scores(query) search(anns_field=“sparse”)

10.2 get_scores 返回什么

返回一个 numpy 分数数组,长度 = 语料文档数,scores[i] = 第 i 篇文档的分数。下标 = 文档在 corpus 里的位置序号,不带业务 doc id,不排序,未命中是 0。

要拿 (id, 分数, 排序) 得自己 zip + sort,或用 get_top_n 直接拿 top-K 文档。

10.3 只做了检索流程的一件事

query ──① 定位候选 ──② 打分 ──③ 排序取 Top-K
        (倒排索引)    (BM25)    (sort+截断)

rank_bm25 只做 ②,而且对全语料每篇都打分:

  • ❌ 没倒排索引,不定位候选
  • ✅ 有 BM25 公式
  • ❌ 不排序截断

复杂度 O(全语料),不管 query 命中几个词。这正是它简单但低效、进不了生产的根源。

10.4 生产里加文档只能全量重建

# rank_bm25 没有 bm25.add(new_docs)
tokenized_corpus += new_docs
bm25 = BM25Okapi(tokenized_corpus)   # 全量重建!

因为统计量是语料级的,加一篇新文档,所有词的 df/IDF/avgdl 都可能变,没法局部更新。所以 rank_bm25 定位是:离线批处理、静态语料、几千~几万文档、实验原型。

11. Milvus 的 add:原生增量

11.1 add 就是 insert / upsert

client.insert("hybrid_docs", [
    {"text": "新文档1", "dense": emb_model.encode("新文档1").tolist()},
])
# sparse 字段由 BM25 Function 自动算,不用手动给

11.2 内部怎么走

insert(新文档)
  → 写入 growing segment(内存增量,只追加)
     · BM25 Function 自动分词+算权重→生成 sparse
     · 新词分配新 dim_id,统计量内存累加
  → growing 段暴力可查(近实时可见)
  → seal:固化成 sealed segment,建稀疏+稠密索引
  → 后台 compaction:合并小段,统计量重算

老索引全程不重建,写入不阻塞查询。

11.3 对比

rank_bm25 Milvus
加新文档 API ✅ insert/upsert
代价 全量重建 O(全语料) 追加 O(新文档量)
老索引动不动 动(重建) 不动
写入阻塞查询 阻塞 不阻塞
统计量更新 重建时一把算 segment 增量 + compaction

11.4 IDF 漂移

增量写入带来 BM25 固有问题:新文档改变 df/avgdl,老查询分数会变。不是 bug,是 BM25 设计如此。

  • 冷启动先灌主数据再开放查询。
  • 大数据量稳定后影响趋于 0。
  • 别用固定分数阈值,用相对排名。

12. ES 里的 BM25 和分词器

12.1 BM25 参数可调

"similarity": {"default": {"type": "BM25", "k1": 1.2, "b": 0.75}}
参数 默认 作用 怎么调
k1 1.2 词频饱和速度 想强调频次→调大;短文本→调小
b 0.75 长度归一化强度 长短混杂→保持;长度均匀→调小

默认 (1.2, 0.75) 是公认好起点,有标注集再网格搜索。

12.2 分词器生态

ES 中文主流用 IK:支持 ik_max_word/ik_smart 双模式、自定义词典、热更新词典(新词不用重启)。这是 ES 相对 Milvus 在纯 BM25 全文检索上的最大优势。

12.3 ES vs Milvus BM25 对比

维度 Elasticsearch Milvus
分词器 极成熟(IK/Smartcn/同义词/词干化) 内置中文,可定制性弱
BM25 参数 k1/b 可调,可按字段 不暴露
混合检索 需外部接向量+自己融合 原生 hybrid_search + RRF
全文检索能力 高亮/phrase/slop/fuzzy 齐全 仅基础 BM25
适合 纯关键词/成熟全文检索 向量为主、BM25 辅助的混合

一句话:纯 BM25 + 复杂全文检索 → ES;BM25 和向量同系统融合 → Milvus。两者 BM25 数学上一样,差别在分词器生态和工程集成度。

13. 总结:核心心智模型

13.1 职责划分

文本 ─①分词器──> 词项 ─②引擎──> 词表+倒排索引 ─③BM25──> 权重 ─④检索──> Top-K
       analyzer             Milvus/ES           打分公式
  • 分词 = analyzer 的活
  • 词表/倒排索引 = 引擎的活
  • 打分 = BM25 的活(只是消费者,不管前两步)

13.2 五个最容易记混的点

  1. BM25 不管分词、不管建索引、不管维护词表——它只是个打分公式。
  2. rank_bm25 是打分器不是检索引擎:没倒排索引、没增量、加文档要全量重建,只适合实验。
  3. Milvus/ES 是完整引擎:分词 + 倒排索引 + 增量 segment + 打分,insert 时 BM25 Function 自动算稀疏向量。
  4. 稀疏维度 = 词表大小(不固定、只增不减),稠密维度 = 模型定死,两者不一致也不需要对齐,靠 hybrid 在排名层融合。
  5. 分词是 BM25 的天花板——分词切错,后面全白搭。

13.3 选型一句话

  • 离线/实验/小数据 → rank_bm25
  • 生产纯 BM25 全文检索 → Elasticsearch
  • 生产 BM25 + 向量混合 → Milvus
  • 超大规模极致性能 → Lucene 直接调 / 自研

13.4 稀疏 vs 稠密一句话

  • Embedding:神经网络把语义编码进稠密向量(黑盒、连续、语义)。
  • BM25:统计公式把词项登记进稀疏向量(透明、离散、词法)。
  • Hybrid:两套平行向量空间,在排名层(RRF/加权)融合,互补盲区。

最后:BM25 是 1994 年的算法,三十年了还是全文检索的标配。不是因为它是银弹,而是它把「词法精确匹配」这件事做到了简单、可解释、够用。现代检索的趋势不是抛弃它,而是把它和稠密向量放一起做 hybrid——精确词项 + 语义同义,两手都要硬。理解了这条链路(分词→词表→倒排索引→BM25→稀疏向量→hybrid),你也就理解了现代 RAG 检索的地基。