AI/ML 详解:语义搜索

Day 22 · 2026-06-08
面向:有编程经验的非 AI 方向工程师
工程对应 → super-individual D11: RAG 实战工程(检索质量、chunk 策略、re-ranking)

BM25 词法检索BM25 / Lexical Retrieval

稀疏检索统计打分
一句话类比

BM25 就是 Elasticsearch / Lucene 默认那个全文搜索打分函数——你早就在用。把它想成数据库倒排索引(inverted index)之上的一个相关性公式:给定 query 里的词,给每篇文档算一个"含金量分数"再排序。本质是 SQL LIKE 的统计学升级版——不只看"含不含关键词",而是看含得有多到位

它解决什么问题 + 工作机制

最朴素的相关性打分是数关键词出现次数(词频 TF)。但这有两个坑:(1) 常见词如"的""is"出现极多却没区分度;(2) 长文档天然词多,会不公平地占优。BM25 正是修这两个坑的:

  • IDF(逆文档频率)——一个词在越少文档里出现,它越"稀有"、越有区分度,权重越高。"量子纠缠"比"系统"值钱;
  • 词频饱和——一个词出现 1 次和 10 次差别大,10 次和 100 次差别小。BM25 让分数随词频趋于饱和,而不是线性暴涨;
  • 长度归一化——按文档相对长度打折,避免长文档靠"词多"刷分。

核心打分公式(对 query 里每个词 qi 求和):

score(D,Q) = Σi  IDF(qi) · f(qi,D)·(k₁+1) / [ f(qi,D) + k₁·(1 − b + b·|D|/avgdl) ]

把符号翻译成人话: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,BM25 就过时了"——大错。BM25 在精确匹配上依然是王者:产品型号(iPhone 15 Pro)、人名、错误码、代码标识符、罕见专有名词——这些 embedding 反而容易"语义漂移"成近义词。2024–2025 的主流检索系统几乎都保留 BM25 作为一路,而不是丢弃它。
📌 BigCat 场景:搭建个人知识库搜索时,对代码片段、API 名、论文标题这类"字面就是答案"的内容,BM25 往往比纯向量更准、更便宜、零 GPU——别一上来就只上向量库。
Takeaway + 思考题
💡 BM25 不是"老土的关键词搜索",而是一个调过几十年的统计相关性函数——精确匹配场景里它依然打不过。
🤔 你的检索需求里,有多少其实是"找一个确切的词/ID"而非"找语义相近的概念"?

稠密检索Dense Retrieval

向量检索语义
一句话类比

把每段文本用神经网络编码成一个固定维度的向量(embedding),检索就变成"在高维空间找最近邻"。类比 geohash:地理上靠近的点编码后坐标也靠近——稠密检索是把这套思路从"地理近"换成"语义近"。"汽车""轿车""automobile"在空间里挤成一团,哪怕一个字都不重叠。

它解决什么问题 + 工作机制

BM25 的死穴是词不匹配(vocabulary mismatch):query 写"如何降低延迟",文档写"优化响应时间",字面零重叠 → BM25 检索不到。稠密检索用神经网络把语义编码进向量,绕开字面。机制叫 bi-encoder(双编码器)

  • query 和 document 各自过一个编码器,得到两个向量;
  • 点积或余弦相似度打分:sim(q,d) = Eq(q) · Ed(d),越大越相关;
  • 文档向量可离线预先算好存进向量库,查询时只编码 query——这是它能扩展到亿级文档的关键。

怎么训练这个编码器?对比学习(contrastive learning):给一对"问题—正确段落",把它俩的向量拉近;同 batch 里其他段落当负样本推远(in-batch negatives)。Karpukhin 等人的 DPR 论文 (arXiv 2004.04906, 2020) 证明:在开放域问答上,稠密检索的 top-20 召回准确率比 Lucene-BM25 高 9–19 个百分点——语义检索第一次在大规模任务上系统性胜过词法检索。

Bi-encoder:query 与 doc 各自编码,离线索引

queryEncoder_q [0.2, -0.7, ...]
doc(百万篇)Encoder_d 向量库(离线预算
查询时:只算 query 向量 → 在向量库找最近邻 → top-k 段落
代码示例
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 强"——。它有三个软肋:(1) 跨领域(out-of-domain)——编码器没见过的领域(医学、法律、你公司黑话)召回会塌;(2) 精确匹配——型号、ID 容易被它"理解"成近义词;(3) 长尾罕见实体。所以业界很少用稠密检索——下一张卡讲怎么和 BM25 合体。
📌 BigCat 场景:跨学科笔记搜索的杀手锏——你用"佛学的无常"去搜,能命中你三个月前写的"分布式系统里没有永久状态"。这种跨域语义联想正是稠密检索的主场,BM25 完全做不到。
Takeaway + 思考题
💡 稠密检索把"找词"变成"找意思"——它的强项(语义泛化)和弱项(精确匹配)恰好和 BM25 互补。
🤔 如果编码器没见过你的专业领域,它的"语义"还可信吗?这暗示了什么时候该微调 embedding?

混合检索Hybrid Search

融合排序RRF
一句话类比

既然 BM25 强在精确词法、稠密检索强在语义,那就别二选一——两路都召回,再融合排序。这就是集成学习(ensemble)的思路,也像数据库查询优化器综合多个索引、或推荐系统的多路召回 + 融合。一个查 query 走两条路,最后把两份排名合成一份。

它解决什么问题 + 工作机制

单路检索总有盲区:BM25 漏掉换了说法的,稠密漏掉罕见专名的。混合检索同时跑两路,难点在怎么融合。两种主流做法:

  • ① 分数加权:final = α·BM25 + (1−α)·cosine。问题:BM25 分数可以是 0–30 任意范围,余弦相似度是 0–1,量纲完全不同,直接相加等于让 BM25 碾压余弦。要先归一化,但归一化对异常值很脆。
  • ② RRF(Reciprocal Rank Fusion,倒数排名融合)只用排名、不用原始分数,从根上回避量纲问题。

RRF 的公式极简——每个文档 d 的融合分数:

RRF(d) = Σ检索器 i   1 / ( k + ranki(d) )

直觉:一个文档在某一路排名越靠前(rank 越小)1/(k+rank) 越大,贡献越多;在两路都靠前的文档分数叠加,自然冒头。k(通常取 60)是个平滑常数——避免 rank=1 的文档分数过度碾压第 2、3 名,让排名靠后的也还有发言权。Cormack 等人 2009 (SIGIR) 证明:这个朴素到不像话的公式,融合效果竟稳定胜过各单路方法、也胜过更复杂的 Condorcet 融合。

混合检索流程

query
├→ BM25 召回 排名 A
└→ 稠密召回 排名 B
        ↓ RRF 融合(只看排名)
        最终 top-k
代码示例
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 两路都靠前 → 冒头
常见误区 + 实践场景
"直接把 BM25 分和余弦分相加就行"——。两者量纲差一个数量级,相加等于只用了 BM25。要么严格归一化(脆),要么用 RRF 只比排名(鲁棒,几乎零调参)。生产首选 RRF——它没有需要per-query 调的 α。
📌 BigCat 场景:个人知识库的"全都要"配置——搜"PPO 公式"时 BM25 精准命中术语,搜"那个讲探索与利用权衡的笔记"时稠密路捞到语义。RRF 融合让你一个搜索框同时吃下两种意图
Takeaway + 思考题
💡 混合检索是检索界的"集成学习"——RRF 用"只比排名"这一招优雅绕过量纲难题,几乎免调参。
🤔 当两路检索给出完全冲突的 top-1 时,RRF 的"民主投票"一定对吗?什么场景下你更该信任其中一路?

HNSW 向量索引Hierarchical Navigable Small World

近似最近邻ANN
一句话类比

稠密检索说"找最近邻"——但百万、十亿向量,每次 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)最主流的索引。它的两个核心思想:

  • Small World(小世界图)——每个节点连到若干近邻,但也保留少量"长边",让任意两点间跳几步就能到(六度分隔那个直觉);
  • Hierarchical(分层)——最高层节点极少、边极长,像高速公路;越往下层节点越密、边越短,像市区小路。查询从最高层贪心走到局部最近,再下沉一层细化,逐层逼近。
HNSW 分层导航(类比跳表)

L2(稀疏·长跳)  ─────────
                ↓ 下沉
L1(中等)     ──────
                  ↓ 下沉
L0(全量·短边) ★=最近邻
从顶层粗定位 → 逐层细化 → 在底层锁定最近邻,总跳数 ≈ log n

三个关键参数就是 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 的本质
常见误区 + 实践场景
"向量库返回的就是精确最近邻"——。HNSW 是近似,recall 几乎不会是 100%:它可能漏掉真正最近的那个。ef_search 调大召回升、延迟也升,这是明确的 trade-off 旋钮,不是 bug。需要 100% 精确(如去重、合规)时,要么用暴力精确检索,要么对 HNSW 结果做精排兜底。
📌 BigCat 场景:个人知识库到几万条时,pgvector / Qdrant 默认就用 HNSW。理解 ef_search 这个旋钮,你才能在"搜得快"和"别漏重要笔记"之间按需调——而不是被默认值悄悄坑了召回。
Takeaway + 思考题
💡 HNSW = 高维空间里的跳表,用"分层 + 小世界图"把最近邻搜索压到 O(log n),代价是放弃 100% 精确。
🤔 你愿意为 50× 的检索提速,接受 1% 的最近邻被漏掉吗?这个 recall/延迟权衡该由谁、按什么标准来定?
工程对应 → super-individual D11(RAG 检索工程实战)

深入资源Further Reading

深入思考Deep Questions

1. BM25 和稠密检索,本质上是"符号匹配"和"分布式表示"两种范式之争。这和 AI 历史上 symbolic vs connectionist 之争有什么呼应?
这是一个漂亮的微缩版。BM25 是符号派(symbolic)——词就是离散符号,相关性来自符号统计(词频、文档频率),可解释、可调、零训练,但脆在"同义不同词"。稠密检索是联结派(connectionist)——意义弥散在向量的各个维度里(分布式表示),泛化强但是黑箱,你说不清第 137 维代表什么。AI 史上这两派斗了几十年,而 2020 年后检索领域的答案恰恰是不选边、要混合——RRF 让符号派的精确和联结派的泛化各取所长。这其实是更普遍的工程智慧:当两个范式各有不可替代的强项时,融合往往胜过纯化。BigCat 你的分布式背景对此应有共鸣——CAP 定理下没有银弹,最终架构总是按场景做权衡组合,而非追求某个理论最优。
2. 稠密检索把语义压成一个固定维度向量——这个"压缩"必然有损。哪些信息在这一步被丢掉了?这解释了它的哪些失败模式?
把一整段话压成 384 或 1024 维向量,是极激进的有损压缩——必然丢东西。丢掉的主要是:(1) 精确的字面 token——"GPT-4o"和"GPT-4"在向量空间可能几乎重合,因为模型抓的是"它们都是 OpenAI 模型"这个语义,而非字面差异,这直接解释了精确匹配的失败;(2) 组合结构——bi-encoder 把整句压成一个点,"猫追狗"和"狗追猫"可能很近,因为词袋语义相似;(3) 长尾罕见信息——编码器训练时少见的实体,表示质量差。这也是为什么有 late-interaction(如 ColBERT,把每个 token 各留一个向量、检索时细粒度交互)这类方案——它们用更多存储换回被压缩丢掉的 token 级信息。理解"向量 = 有损压缩",你就能预测它什么时候会出错:凡是字面精度细粒度结构要紧的场景,单向量稠密检索就危险。
3. HNSW 是"近似"换速度。在哪些场景下,1% 的召回损失是灾难性的、绝不能用 ANN?反过来,哪些场景下精确检索是过度工程?
关键看漏掉一个结果的代价。绝不能用 ANN(或必须精排兜底)的场景:(a) 去重 / 实体解析——漏掉那个真正的重复项,会让脏数据混进库,下游全错;(b) 合规 / 安全——比如检索"这段文本是否匹配已知违禁内容库",漏一个就是事故;(c) 金融对账 / 唯一性校验——任何要求"必须找到就是它"的场景。反过来,精确检索是过度工程的场景:(a) RAG 喂给 LLM 的候选段落——反正要召回 top-20 再让 LLM 筛,漏掉第 8 名通常无伤大雅;(b) 推荐 / 探索——本来就没有"唯一正确答案",多样性甚至比精确更可取;(c) 海量低价值数据——为 1% 召回付 50× 算力不划算。判断公式很朴素:(漏检概率 × 单次漏检代价) vs (精确检索的额外成本)。BigCat 你的个人知识库属于前述"RAG 候选"类——HNSW 默认值足够,不必上精确检索。
4. RRF 用 k=60 这个"魔法常数"。它到底在调什么?如果设成 k=1 或 k=1000,融合行为会怎么变?
k 调的是"头部排名的话语权"。回看公式 1/(k+rank):当 k 很小(如 1),rank=0 的文档贡献 1/1=1,rank=1 的贡献 1/2——头部差距被放得极大,融合几乎被各路 top-1 主导,后面的几乎没机会。当 k 很大(如 1000),rank=0 贡献 1/1000、rank=1 贡献 1/1001——几乎一样,排名差异被抹平,融合退化成"近似只看一个文档在多少路里出现过"(趋近投票计数)。k=60 是 Cormack 论文里实验出的折中:既让头部有合理优势,又不让某一路的 top-1 一票否决另一路。这是个典型的平滑超参——和你熟悉的拉普拉斯平滑、学习率里的 epsilon 同源:用一个常数防止"除以小数爆炸",在"信任头部"和"兼顾长尾"之间找平衡点。值得记住的元认知:很多工程里的"魔法常数",本质都是某个 trade-off 旋钮的经验默认值——知道它在调什么,比记住数值重要。
5. 把今天四个概念串起来:一个生产级语义搜索系统,数据和查询是怎么流动的?每个环节的瓶颈在哪?
串成一条完整流水线:① 离线索引——文档切 chunk,一路过 BM25 建倒排索引,一路过 embedding 模型编码成向量、灌进 HNSW 图。瓶颈:embedding 算力(百万文档要批量 GPU)+ HNSW 建图时间。② 在线查询——query 同时走两路:BM25 倒排查 top-N、向量编码后用 HNSW 找近似 top-N。瓶颈:稠密路要实时编码 query(有延迟),HNSW 的 ef_search 决定召回/延迟。③ 融合——RRF 把两路排名合成一份。瓶颈几乎没有,RRF 极轻。④ 精排(可选)——对融合后的 top-k 用 cross-encoder 或 LLM re-ranker 精排(这一步属于工程实战,见 super-individual D11)。⑤ 喂给 LLM——top 段落进 context。整条链的核心权衡:召回宽度(每路捞多少)× 各环节延迟 × 算力成本。理解今天四块拼图,你就能看懂任何向量库/RAG 框架的架构图——它们都是这五步的不同封装。下一步的"chunk 怎么切、re-ranker 怎么选、检索质量怎么评",是 super-individual D11 的工程主场。