BM25 就是 Elasticsearch / Lucene 默认那个全文搜索打分函数——你早就在用。把它想成数据库倒排索引(inverted index)之上的一个相关性公式:给定 query 里的词,给每篇文档算一个"含金量分数"再排序。本质是 SQL LIKE 的统计学升级版——不只看"含不含关键词",而是看含得有多到位。
最朴素的相关性打分是数关键词出现次数(词频 TF)。但这有两个坑:(1) 常见词如"的""is"出现极多却没区分度;(2) 长文档天然词多,会不公平地占优。BM25 正是修这两个坑的:
核心打分公式(对 query 里每个词 qi 求和):
把符号翻译成人话:IDF(qi) = 这个词有多稀有(罕见词权重大);f(qi,D) = 词在文档里出现几次;k₁(典型 1.2–2.0)= 控制词频饱和的速度,越小越快饱和;b(典型 0.75)= 长度惩罚强度,0 表示完全不管文档长度,1 表示全力惩罚;|D|/avgdl = 这篇文档比平均长还是短。整个分母就是"词频 + 长度惩罚",让分子的词频不会无限膨胀。
from rank_bm25 import BM25Okapi # pip install rank-bm25 corpus = [ "猫科动物的睡眠周期研究", "如何给 Postgres 配置连接池", "分布式系统中的缓存一致性", ] # BM25 基于词,中文需先分词(这里简化为按字/空格) tokenized = [list(doc) for doc in corpus] bm25 = BM25Okapi(tokenized) # 内部建倒排索引 + 算 IDF query = list("缓存一致性") scores = bm25.get_scores(query) # 每篇文档一个 BM25 分 print(scores) # 第 3 篇分数最高——精确词命中
把每段文本用神经网络编码成一个固定维度的向量(embedding),检索就变成"在高维空间找最近邻"。类比 geohash:地理上靠近的点编码后坐标也靠近——稠密检索是把这套思路从"地理近"换成"语义近"。"汽车""轿车""automobile"在空间里挤成一团,哪怕一个字都不重叠。
BM25 的死穴是词不匹配(vocabulary mismatch):query 写"如何降低延迟",文档写"优化响应时间",字面零重叠 → BM25 检索不到。稠密检索用神经网络把语义编码进向量,绕开字面。机制叫 bi-encoder(双编码器):
怎么训练这个编码器?对比学习(contrastive learning):给一对"问题—正确段落",把它俩的向量拉近;同 batch 里其他段落当负样本推远(in-batch negatives)。Karpukhin 等人的 DPR 论文 (arXiv 2004.04906, 2020) 证明:在开放域问答上,稠密检索的 top-20 召回准确率比 Lucene-BM25 高 9–19 个百分点——语义检索第一次在大规模任务上系统性胜过词法检索。
from sentence_transformers import SentenceTransformer, util # Sentence-BERT:把句子编码成可比较的向量 model = SentenceTransformer("all-MiniLM-L6-v2") corpus = ["优化数据库响应时间的几种方法", "猫的睡眠习性"] doc_emb = model.encode(corpus, convert_to_tensor=True) # 离线预算 q_emb = model.encode("如何降低系统延迟", convert_to_tensor=True) hits = util.cos_sim(q_emb, doc_emb)[0] # 余弦相似度 print(hits) # 第 1 篇分高——字面零重叠但语义命中
既然 BM25 强在精确词法、稠密检索强在语义,那就别二选一——两路都召回,再融合排序。这就是集成学习(ensemble)的思路,也像数据库查询优化器综合多个索引、或推荐系统的多路召回 + 融合。一个查 query 走两条路,最后把两份排名合成一份。
单路检索总有盲区:BM25 漏掉换了说法的,稠密漏掉罕见专名的。混合检索同时跑两路,难点在怎么融合。两种主流做法:
RRF 的公式极简——每个文档 d 的融合分数:
直觉:一个文档在某一路排名越靠前(rank 越小),1/(k+rank) 越大,贡献越多;在两路都靠前的文档分数叠加,自然冒头。k(通常取 60)是个平滑常数——避免 rank=1 的文档分数过度碾压第 2、3 名,让排名靠后的也还有发言权。Cormack 等人 2009 (SIGIR) 证明:这个朴素到不像话的公式,融合效果竟稳定胜过各单路方法、也胜过更复杂的 Condorcet 融合。
def rrf(rank_lists, k=60): # rank_lists: 每路检索返回的 doc_id 有序列表(按相关性降序) scores = {} for ranking in rank_lists: for rank, doc_id in enumerate(ranking): # rank 从 0 起 # 只用排名,不碰原始分数 → 天然免量纲问题 scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1) return sorted(scores, key=scores.get, reverse=True) bm25_top = ["d3", "d1", "d7"] # BM25 这一路的排名 dense_top = ["d1", "d3", "d9"] # 稠密这一路的排名 print(rrf([bm25_top, dense_top])) # d1/d3 两路都靠前 → 冒头
稠密检索说"找最近邻"——但百万、十亿向量,每次 query 都暴力算所有距离 = 全表扫描 O(n),没法用。HNSW 给向量空间建了个分层跳表(skip list):高层稀疏、一步跳很远;低层密集、一步走很近。你对 Redis 的 sorted set 跳表已经熟得不能再熟——HNSW 就是把跳表从一维数轴搬到高维空间的图上,让最近邻搜索从 O(n) 降到约 O(log n)。
精确最近邻在高维下没有捷径,必须近似。这类算法叫 ANN(Approximate Nearest Neighbor,近似最近邻)——牺牲一点召回,换几个数量级的速度。HNSW 是 Malkov & Yashunin (arXiv 1603.09320) 提出的、目前向量库(Faiss、Milvus、pgvector、Qdrant)最主流的索引。它的两个核心思想:
三个关键参数就是 recall(召回) vs 速度/内存的旋钮:M = 每个节点的边数(大 → 召回高、内存大);ef_construction = 建图时的候选广度(大 → 图质量高、建得慢);ef_search = 查询时的候选广度(查询时实时可调,大 → 召回高、慢)。生产里 ef_search 是你最常拧的旋钮。
import hnswlib, numpy as np # pip install hnswlib dim, n = 384, 10000 data = np.random.rand(n, dim).astype("float32") index = hnswlib.Index(space="cosine", dim=dim) index.init_index(max_elements=n, ef_construction=200, M=16) # 建图 index.add_items(data) index.set_ef(50) # ef_search:查询时召回/速度旋钮,可随时调 q = np.random.rand(dim).astype("float32") labels, dists = index.knn_query(q, k=5) # 近似 top-5,约 O(log n) print(labels) # 近似——可能漏掉真正第 1 名,这是 ANN 的本质