搜索核心算法完全指南:从倒排索引到深度学习检索
你打开百度,敲下"苹果",按回车。0.3 秒后,你看到了超过 1 亿条结果。
但你有没有想过:这 0.3 秒里,到底发生了什么?
很多人以为搜索就是一个"匹配"的过程——你输入"苹果",系统找到包含"苹果"两个字的网页,返回给你。听起来简单得像查字典。
但如果你真这么理解,那你错过了一个极其精彩的世界。
真实的搜索引擎里,至少有七个"关卡",每一关都在决定哪些网页值得出现在你面前。倒排索引负责"大海捞针",BM25 和 TF-IDF 给相关性打分,向量检索把语义理解带进来,混合检索把多种武器组合使用,Query 理解让系统知道你搜的到底是水果还是手机,排序学习(LTR)则像一位经验老到的编辑,把最终结果排成最符合你需求的顺序。
这篇文章,就是这七关的完整攻略。读完你会发现:一个"简单"的搜索框,背后藏着半个计算机科学史的智慧结晶。
一、搜索引擎的"心脏":倒排索引原理
先从一个你每天都在经历的场景说起。
假设你是图书馆管理员,你的任务是:用户说一个词,你能立刻告诉他哪些书里提到了这个词。传统做法是把每本书翻一遍,找到那个词。这叫正排索引——以文档为中心,去找词。
但用户问的词五花八门,如果每次都翻遍所有书,图书馆得烧了。于是你换了个思路:建一张大表,按字母顺序列出所有词,每个词后面跟一个"哪些书里有这个词"的清单。这张表,就是倒排索引(Inverted Index)。
你不需要大海捞针——因为你手里已经有一张"针"的位置图。这就是 Google、Elasticsearch、Meilisearch 等所有主流搜索引擎的核心数据结构。
倒排索引是怎么建出来的?
假设有三篇文档:
- 文档 A:"深度学习是机器学习的子领域"
- 文档 B:"机器学习广泛应用于搜索推荐"
- 文档 C:"深度学习在搜索系统中的应用"
经过分词和标准化处理后,构建出的倒排索引结构如下:
每个倒排列表里,不仅记录"哪些文档有这个词",还记录词在每个文档中的位置信息——这在短语查询和邻近查询时非常有用。
from collections import defaultdict
def build_inverted_index(documents):
"""构建倒排索引"""
index = defaultdict(list)
for doc in documents:
doc_id = doc["id"]
words = doc["text"].split()
seen = set()
for pos, word in enumerate(words):
if word not in seen:
index[word].append({"doc_id": doc_id, "pos": pos})
seen.add(word)
return dict(index)
def search(index, query):
"""简单词频计数检索"""
doc_scores = defaultdict(int)
for word in query.split():
if word in index:
for posting in index[word]:
doc_scores[posting["doc_id"]] += 1
return sorted(doc_scores.keys(), key=lambda d: doc_scores[d], reverse=True)
docs = [
{"id": 1, "text": "深度学习是机器学习的子领域"},
{"id": 2, "text": "机器学习广泛应用于搜索推荐"},
{"id": 3, "text": "深度学习在搜索系统中的应用"},
]
print(search(build_inverted_index(docs), "深度学习")) # [1, 3]
倒排索引的厉害之处在于:它的检索复杂度是 O(k),其中 k 是查询词的数量,与文档总数无关。这意味着,即使文档量从 100 万增长到 100 亿,查询速度仍然只取决于你敲了几个字。
二、经典检索算法:BM25 与 TF-IDF
有了倒排索引,我们知道哪些文档包含哪些词了。但问题来了:如果查询"深度学习",文档 A 和文档 C 都包含"深度学习",该把谁排前面?这就是相关性评分要解决的问题。
TF-IDF:词有多重要?
TF-IDF 是信息检索领域最经典、最朴素、也最经受住时间考验的算法之一。它的核心思想:
- TF(词频):一个词在文档里出现得越多,说明它越重要。
- IDF(逆文档频率):一个词在越多文档里出现,说明它越"烂大街",越不重要。
其中 IDF(t) = log(总文档数 / 包含词 t 的文档数)
但 TF-IDF 有一个致命问题:它不会考虑词在文档中的饱和效应。一个词在文档里出现 100 次,就真的比出现 10 次重要 10 倍吗?不一定——可能只是作者在凑字数。
BM25:更聪明的 TF-IDF
BM25(Best Matching 25)在 1994 年被提出,是所有现代搜索引擎相关性评分的基础。它在 TF-IDF 的基础上引入了两个关键改进:词频饱和和文档长度归一化。
当 tf 较小时,分数随词频线性增长;但当 tf 超过某个阈值,继续增加词频带来的分数提升就微乎其微了。这就是"饱和效应"——说三遍重要,说三十遍不会更重要。
BM25 统治了信息检索领域近 30 年。Elasticsearch 从 Lucene 3.0 开始就把默认算法换成了 BM25。
import math
def bm25_score(doc, query, avg_doc_len, k1=1.5, b=0.75):
"""计算单篇文档对查询的 BM25 得分"""
doc_len = len(doc.split())
score = 0.0
for term in query.split():
tf = doc.lower().split().count(term.lower())
if tf == 0: continue
df = 100 # 假设有 100 篇文档包含该词
idf = math.log((10000 - df + 0.5) / (df + 0.5) + 1)
numerator = tf * (k1 + 1)
denominator = tf + k1 * (1 - b + b * doc_len / avg_doc_len)
score += idf * numerator / denominator
return score
doc = "深度学习在搜索系统中的应用非常广泛"
query = "深度学习 搜索"
print(f"BM25 Score: {bm25_score(doc, query, avg_doc_len=200):.4f}")
三、向量检索:ANN 近似最近邻搜索
BM25 很好,但它有一个根本性局限:它只能做字面匹配。你搜"苹果",它能找到包含"苹果"两个字的文档。但"苹果手机"和"iPhone 16 Pro"语义上高度相关,BM25 给它们打的分可能天差地别——因为这两个短语几乎没有字面重叠。这就是语义鸿沟。
打破这个僵局的方法,是把文本变成向量。现代的做法是用深度学习模型(如 BERT、text2vec、M3E、bge-m3)把一段文本编码成一个固定长度的向量,通常是 768 维或 1024 维的浮点数数组。这个过程叫向量化或编码。
向量化之后,"苹果"和"iPhone"在向量空间里会非常接近——因为它们在语义上指代同一种东西。向量检索的本质,就是在这个高维空间里,找到和你查询向量最相似的那些文档向量。
ANN:大海捞针的正确姿势
向量空间里可能有几十亿个向量,暴力计算两两相似度是不现实的。这就要靠 ANN(Approximate Nearest Neighbor,近似最近邻搜索)了。它的核心思想是:放弃精确找到最近的那一个,换来搜索速度 100 倍以上的提升。
HNSW:跳表 + 多层图的暴力美学
HNSW(Hierarchical Navigable Small World)是目前工业界最流行的 ANN 算法,Elasticsearch 8.0+、Milvus、FAISS 都支持它。
它的设计精妙极了:想象你在一个陌生的大城市里找最近的地铁站。如果城市被分成多个区,每区有一个"导航员"认识区内所有著名地标,你就可以先问导航员,快速缩小范围。HNSW 就是这个原理:用多层图结构,从粗糙层快速收敛到精确层。
IVF-PQ:聚类 + 压缩的工程美学
另一种主流 ANN 算法是 IVF-PQ(倒排索引文件 + Product Quantization)。它的思路是:先通过聚类把向量空间划分成若干"区域"(IVF),查询时先找到最相关的几个区域,再在区域内做精确搜索;每个向量还通过 PQ 压缩成短编码,大幅减少内存占用。
这两种算法各有优劣:HNSW 查询精度高、延迟低,但对内存占用大;IVF-PQ 内存效率高,适合超大规模向量库,但查询精度略逊。在实际生产中,Milvus、Faiss 支持混合使用——先用 IVF 粗筛,再用 HNSW 精排。
四、稠密 vs 稀疏:两种检索范式的世纪对决
说到向量检索和传统检索,你可能已经注意到了:这两种方法本质上是在用不同的方式表示"文本"。
传统的 TF-IDF、BM25 用的是稀疏向量(Sparse Vector)——每个词占一个维度,绝大多数维度是 0。这是自然语言最直观的表示方式,可解释性强,但你搜什么词,系统就只能在那些词上打分。
而用深度学习模型编码出来的叫稠密向量(Dense Vector)——每个维度都有值(不为 0),维度数固定(768、1024 等)。信息被压缩、抽象地分布在所有维度上。这就是为什么"苹果"和"iPhone"在稠密向量空间里能很接近——因为模型学会了把它们映射到相似的位置。
这两种方法各有擅长:
| 维度 | 稀疏检索 (BM25) | 稠密检索 (Vector) |
|---|---|---|
| 匹配方式 | 字面匹配(exact match) | 语义匹配(semantic match) |
| 查询词依赖 | 强依赖,用户必须用对词 | 弱依赖,同义词自动理解 |
| 未见词处理 | 完全无法处理(OOV) | 泛化能力强 |
| 可解释性 | 高(哪个词命中一目了然) | 低(黑盒向量运算) |
| 精确检索 | ✅ 强(人名、型号、ID 等) | ❌ 弱 |
| 同义词/语义 | ❌ 需要额外处理 | ✅ 天然支持 |
一个经典的例子:用户搜"手机",BM25 找不到一篇只写"iPhone"但从没出现"手机"这个词的文章,但向量检索可以。这就是稠密检索最大的价值——跨越语言的语义鸿沟。
五、混合检索:让 1+1 大于 2
既然稀疏检索和稠密检索各有优劣,那最好的方案就是——两个都要。
混合检索(Hybrid Search)就是同时利用稀疏和稠密检索结果,再通过特定策略合并打分。实际生产中,这是 RAG(检索增强生成)系统的标配。
关键问题是:两种检索结果的打分尺度完全不同——BM25 分数可能是几十到几百,向量余弦相似度是 0 到 1 之间。怎么比?
RRF:无需调参的融合神器
Reciprocal Rank Fusion(RRF,倒数排名融合)是一种经典的无参融合方法:
其中 k 是一个小常数(通常 k=60),rank(d) 是文档 d 在各检索结果中的排名位置。
它的核心思想是:只关心"排第几",不关心"分多少"。文档在 BM25 结果里排第 3,在向量结果里排第 5,那它的 RRF 分数就是 1/(60+3) + 1/(60+5) = 0.0159 + 0.0154 = 0.0313。
这个方法的好处是:不需要任何训练,不需要调参,对两种检索结果的尺度差异完全免疫。
from collections import defaultdict
def rrf_fusion(results_list, k=60):
"""
RRF 倒数排名融合
results_list: 多个检索结果列表,每个元素是按相关性排序的 doc_id 列表
"""
scores = defaultdict(float)
for results in results_list:
for rank, doc_id in enumerate(results, 1):
scores[doc_id] += 1.0 / (k + rank)
return sorted(scores.keys(), key=lambda d: scores[d], reverse=True)
# 示例
bm25_results = [1, 3, 5, 2] # BM25 返回的文档 ID 排序
vector_results = [3, 1, 4, 2] # 向量检索返回的文档 ID 排序
final = rrf_fusion([bm25_results, vector_results])
print(final) # 融合后的最终排序
在 Elasticsearch 8.11+ 中,可以使用 knn 查询和 query 查询的组合,Elasticsearch 内置了 RRF 支持。Pinecone、Weaviate 等向量数据库也都支持混合检索。
六、Query 理解:让搜索引擎真正"看懂"你
到目前为止,我们默认用户输入的 query 是可以直接检索的。但现实中,用户的输入五花八门:可能有拼写错误、可能口语化严重、可能词不达意。
Query 理解,就是让搜索引擎真正"看懂"你在找什么。它包括三个核心环节:
1. 分词:词是语言的最小意义单元吗?
分词(Tokenization)是搜索的第一道关。中国人说话不像英文用空格分词,"机器学习"是一个词还是两个词?"深度学习模型训练"怎么切?
常见的中文分词工具包括 jieba、HanLP、THULAC 等。但对于搜索场景,简单基于词典的分词往往不够——"机器学习"可能被切成"机器"+"学习",也可能被保留为一个整体。这取决于词典质量和上下文。
现代做法是用子词分词(Subword Tokenization),如 BPE、WordPiece、SentencePiece。它们把文本切成更小的、频率最高的子字符串单元,在开放域词汇和精准分词之间取得平衡。这也是 BERT 等模型的分词方式。
2. 意图识别:你到底想要什么?
用户输入"苹果",你真的知道用户想要什么吗?水果?手机?股票?
意图识别(Intent Detection)的目标是把用户模糊的输入归类到一个或多个意图类别。比如电商搜索中,常见的意图包括:找商品、找品牌、比价格、看评价。
实现方式通常是训练一个文本分类模型(如 BERT + Classifier),在标注数据充足的情况下,准确率可以达到 90% 以上。
3. 纠错:搜错了也能找到对的结果
用户打错字是家常便饭——"搜索算法"打成了"搜素算法","机器学习"打成了"机哭学习"。如果系统傻傻地按错误的词去检索,结果一定是灾难性的。
Query 纠错(Query Correction / Spelling Correction)主要有两种策略:
- 编辑距离纠错:基于词典,计算用户输入与词典词的编辑距离(Levenshtein Distance),找出最接近的候选词。比如"搜素"→"搜索"的编辑距离是 1,系统自动纠正。
- 语言模型纠错:基于大规模语料训练的语言模型,判断输入是否为"正常语句"。比如"机哭学习"在语言模型中的概率极低,系统识别为疑似错误词。
值得注意的是:纠错要谨慎。学术查询"CNN"如果被纠错成"CN",结果会完全跑偏。所以通常只对低置信度的纠错进行自动应用,高置信度意图保持不变。
七、排序学习 LTR:从人工调参到机器学习
好了,现在你已经有了候选文档列表,并且有了相关性分数。最后一步:把这些文档排成什么顺序展示给用户?
最朴素的做法是:直接按相关性分数排序。但这忽略了一个关键问题——相关性不等于用户体验。
一个文档可能和查询"非常相关"(BM25 分数高),但用户真正想要的,可能是更新、更权威、点击率更高的文档。这就需要排序学习(Learning to Rank, LTR)。
LTR 的核心思想
LTR 把搜索排序问题建模成一个机器学习问题。它的输入是每个文档的多个"特征"(feature),输出是排序分数。
这些特征包括:
- 相关性特征:BM25 分数、TF-IDF 分数、向量相似度
- 文档质量特征:PageRank、权威性得分、内容长度
- 时效性特征:文档发布时间、更新时间
- 用户信号特征:点击率、浏览时长、收藏率
模型的任务是:给定查询和文档特征,预测一个分数,使得最终排序能最大化用户满意度(如 NDCG、MAP 等评估指标)。
三大 LTR 方法
| 类型 | 代表算法 | 特点 |
|---|---|---|
| Pointwise | LightGBM + 回归 | 每个文档独立打分,简单但忽略了文档间关系 |
| Pairwise | LambdaMART、RankNet | 学习文档对的相对顺序,更接近排序本质 |
| Listwise | ListNet、LambdaRank | 直接优化整个列表的 NDCG,效果最好但计算成本高 |
在实际生产中,LambdaMART(基于梯度提升树的 Pairwise 方法)是最流行的选择。它在 LightGBM、XGBoost 中都有现成实现,训练快、效果好、特征工程友好。
# LambdaMART 训练示例(使用 LightGBM)
import lightgbm as lgb
from sklearn.model_selection import train_test_split
# 假设有特征矩阵 X 和 relevance label(0-4 的相关性等级)
X_train, X_test, y_train, y_test = train_test_split(X, labels, test_size=0.2)
# LightGBM LambdaRank
train_data = lgb.Dataset(X_train, label=y_train)
test_data = lgb.Dataset(X_test, label=y_test, reference=train_data)
params = {
"objective": "lambdarank", # 指定 LambdaRank 目标
"metric": "ndcg", # 用 NDCG 作为评估指标
"ndcg_eval_at": [5, 10], # 评估 NDCG@5 和 NDCG@10
"num_leaves": 63,
"learning_rate": 0.05,
}
model = lgb.train(
params,
train_data,
num_boost_round=500,
valid_sets=[test_data],
callbacks=[lgb.early_stopping(50)]
)
# 预测排序
predictions = model.predict(X_test) # 输出每个文档的排序分数
值得注意的是,LTR 模型的效果高度依赖标注数据的质量。通常需要人工标注"查询-文档"对的相关性等级(0-4 或 0-3),这是最耗时、成本最高的环节。
八、总结:搜索算法的进化之路
好,我们走完了整个搜索算法的七个关卡。现在来回顾一下:
- 倒排索引:解决了"在海量文档中快速找到包含特定词的文档"的问题
- BM25 / TF-IDF:解决了"哪些文档更相关"的打分问题,引入了词频饱和和长度归一化
- 向量检索 + ANN:解决了"语义相近但字面不同的文档"的匹配问题
- 稠密 vs 稀疏:两种检索范式各有优劣,分别擅长精确匹配和语义理解
- 混合检索:用 RRF 等方法把多种检索结果融合,获得最佳覆盖
- Query 理解:让搜索引擎真正"看懂"用户在找什么,而不是机械匹配
- 排序学习 LTR:用机器学习整合所有信号,生成最终排序
这七个环节串起来,就是现代搜索引擎(如 Elasticsearch + 向量插件)或 RAG 系统(如 RAGFlow、AnythingLLM)的核心技术栈。
我认为,搜索算法的进化史,本质上是一部"让机器越来越懂人话"的历史。从字面匹配到语义理解,从人工调参到数据驱动,每一步都在弥合人类表达和机器理解之间的鸿沟。
而大模型时代的到来,把这个进程又往前推进了一大步——现在,Query 理解和文档理解都可以由 LLM 来完成,搜索正在从"找信息"变成"找答案"。
但无论技术怎么变,搜索的核心问题永远不会变:用户想要的,到底是什么?
这个问题,值得每一个搜索工程师持续思考。
参考资料
- Okapi BM25 - Wikipedia
- HNSW: Hierarchical Navigable Small World - Malkov & Yashunin, 2016
- Elasticsearch Hybrid Search (RRF) - Official Documentation
- Learning to Rank with LambdaMART - LightGBM Documentation
- Dense vs Sparse Retrieval - Pinecone Learn
- Query Understanding in Search Systems - Industry Practice