一、为什么"搜不到"往往是"想不到"

传统检索(比如 BM25、倒排索引)本质上是在做词面匹配:查询里出现的词,在文档里也出现过,而且出现得越集中、越稀有,得分越高。这套方法非常快、非常可解释,但它有个致命前提——必须有词汇重叠。用户搜"手机卡顿怎么办",文档里写的是"设备运行缓慢的解决办法",两者几乎没有共同词,BM25 可能直接把这篇最相关的文档排到几十名之后。

更麻烦的是一词多义和同义异形。"苹果"到底是水果还是公司?"笔记本""laptop""电脑"该不该算同一个东西?词袋模型里它们是三个互不相干的符号。我自己的体会是:在客服、问答、语料是"自然语言描述"而非"专业术语"的场景下,纯关键词检索的召回经常是瓶颈,用户看到的不是"没结果",而是"一堆看起来匹配了关键词、其实答非所问的结果"。

语义检索的思路是换一个战场:不再比对字符串,而是比对意思。把文本先压缩成一串数字(向量),让"意思相近的文本"对应到"距离相近的向量",检索就变成了向量最近邻查找。这串数字就叫 Embedding(嵌入向量)。

提示:语义检索不是要取代关键词检索。实践中更稳的路线往往是 混合检索:BM25 负责精确命中型号、人名、报错码,向量检索负责"意思到了但词不一样"的那部分,最后用 RRF 或重排模型合并结果。

二、Embedding 是什么:给"意思"找一个坐标

Embedding 可以理解为一个函数:输入一段文本,输出一个 d 维实数向量(常见的如 384、768、1024 维)。它把文本映射到一个语义空间里,每段文本就是这个空间中的一个点。这个空间不是我们手工设计的,而是模型在训练中"自己长出来"的坐标系。

它的理论直觉来自语言学里的分布式假设:"一个词的含义由它的上下文决定。"经常出现在相似语境里的词,模型就会把它们的向量拉近。早期的 Word2Vec、GloVe 只能给单个词向量;今天做检索基本都用 Sentence Embedding 模型(如 sentence-transformers 系列、bge、e5、gte),它们用 Transformer 读完整句话,再通过池化(mean pooling 或取 CLS 位置)压成一个句向量。

这类模型的关键训练方式是对比学习(contrastive learning):给模型一对文本(比如一个问题和它的正确答案、一句英文和它的中文翻译),训练目标是让这对"正样本"的向量靠近,同时让随机拼凑的"负样本"向量远离。所以它学到的不是"语法规则",而是"什么和什么应该算相近"这个判断能力。这也解释了为什么不同模型的向量不能混用——它们的坐标系是各自独立训练出来的。

三、怎么判断"意思相近":相似度度量

有了向量,"像不像"就变成了几何距离问题。最常用的是余弦相似度:只看两个向量的夹角,不看长度,取值范围 [-1, 1],越接近 1 越相似。公式写成代码风格就是 cos(a, b) = (a · b) / (||a|| * ||b||)。

工程上有个很实用的简化:如果先把所有向量做 L2 归一化(让每个向量长度都变成 1),那么余弦相似度就退化成内积(点积)。于是检索时只需要算一次矩阵乘法,这对性能影响巨大——大规模检索里这是标准做法。sentence-transformers 的 encode(..., normalize_embeddings=True) 就是干这个的。

除了余弦,还有欧氏距离(L2)和内积两种常见度量。选择时要记住一条:度量方式必须和索引的度量方式一致,归一化后的向量配 IP(内积)或 L2 都可以(二者此时等价排序),但没归一化的向量用余弦就需要额外除以模长。我个人的习惯是:一律归一化 + 内积,省心且快。

提示:高维空间里"所有点都差不多远"的现象(维度灾难)确实存在,但对比学习训练出的向量在真实语料上通常分布得比较有区分度。如果发现相似度分数普遍挤在 0.7~0.8,先怀疑模型选得不合适或者没做归一化,而不是先怀疑数学错了。

四、语义检索的完整流程

一次语义检索分成离线建库和在线查询两阶段,理解这个分界线很重要,因为它决定了系统的延迟和成本结构。

离线阶段(可以慢慢做):

  1. 加载文档,做切分(chunking),切成几百字一段的片段;
  2. 用 Embedding 模型把每个片段编码成向量,记录 id、原文、元数据;
  3. 把向量写入向量数据库(FAISS、Milvus、Qdrant、pgvector 等),并构建 ANN 近似最近邻索引(如 HNSW、IVF-PQ)。

在线阶段(必须快):

  1. 用同一个模型把用户 query 编码成向量;
  2. 在 ANN 索引里查 top-k(比如 50 个)候选;
  3. 可选:用重排模型(rerank / cross-encoder)对这 50 个精打分,取前 5~10 返回。

为什么要切分而不是整篇编码?因为一个 512 维向量装不下一篇长文章的所有语义,硬塞进去会得到一个"平均化"的模糊向量,查什么都像一点、查什么都不够准。而 ANN 索引存在的原因是:精确地把 query 和库里百万条向量逐一比较太慢,HNSW 之类的结构用"图跳转"把复杂度从 O(n) 降到近似 O(log n),代价是可能漏掉真正的最近邻——这是个 recall 与 latency 的权衡。

五、动手跑一遍:最小可用的语义检索

下面用 sentence-transformers 写一个几十行的玩具检索器,重点看清楚三件事:编码 → 归一化 → 内积排序。

import numpy as np
from sentence_transformers import SentenceTransformer

# 中文小模型,向量维度 512,CPU 上也能跑
model = SentenceTransformer("BAAI/bge-small-zh-v1.5")

docs = [
    "手机用久了越来越卡,可以先清理后台和缓存",
    "猫咪总是跳上桌子,可以用双面胶和喷水训练它",
    "Python 里列表去重可以转成 set 再转回来",
    "冬天室内干燥,建议使用加湿器并多通风",
]

# 归一化后,内积就等于余弦相似度
doc_emb = model.encode(docs, normalize_embeddings=True)

query = "怎么让猫不要上餐桌"
# bge 系列建议给 query 加检索指令前缀,效果更好
query = "为这个句子生成表示以用于检索相关文章:" + query
q_emb = model.encode([query], normalize_embeddings=True)[0]

scores = doc_emb @ q_emb                      # (n,) 每条文档的相似度
rank = np.argsort(-scores)[:2]                # 取分数最高的两条

for i in rank:
    print(f"{scores[i]:.4f}  {docs[i]}")

当文档量上万以后,就换成 FAISS 的近似索引,用法几乎一样,只是把暴力矩阵乘换成了图索引:

import faiss

d = doc_emb.shape[1]
index = faiss.IndexHNSWFlat(d, 32, faiss.METRIC_INNER_PRODUCT)
index.hnsw.efSearch = 64          # 越大越准、越慢
index.add(doc_emb.astype("float32"))

D, I = index.search(q_emb[None, :].astype("float32"), k=2)
print(I, D)                       # 返回 (下标, 分数)

跑通之后你会发现,"怎么让猫不要上餐桌" 命中的确实是猫咪那条,尽管它和查询没有任何一个关键词重合——这就是语义检索相对于 BM25 的核心增益。

六、工程上的几个坑,我自己踩过的

实践里真正消耗时间的往往不是算法,而是这些细节:

提示:如果检索效果始终上不去,先检查语料质量和切分,再考虑换更大的模型。很多时候是文档本身就写得含糊,向量再强也救不回来。

七、小结与下一步

回到最初的问题:Embedding 是把"意思"变成语义空间中的坐标,语义检索就是在这个空间里做最近邻查找。它的三个支点是——好的向量(对比学习训练出的模型)、合理的度量(归一化 + 内积)、高效的索引(HNSW 这类 ANN 结构)。理解了这三件事,剩下的都是工程细节。

值得继续深入的方向:混合检索(BM25 + 稠密向量 + RRF 融合)、RAG 中检索质量对生成质量的决定性影响、Matryoshka 这类"一个向量多种精度"的可变维表示,以及针对自己领域用少量标注数据做微调——通常这是性价比最高的提升手段。