一、为什么"搜不到"往往是"想不到"
传统检索(比如 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,先怀疑模型选得不合适或者没做归一化,而不是先怀疑数学错了。
四、语义检索的完整流程
一次语义检索分成离线建库和在线查询两阶段,理解这个分界线很重要,因为它决定了系统的延迟和成本结构。
离线阶段(可以慢慢做):
- 加载文档,做切分(chunking),切成几百字一段的片段;
- 用 Embedding 模型把每个片段编码成向量,记录
id、原文、元数据; - 把向量写入向量数据库(
FAISS、Milvus、Qdrant、pgvector等),并构建 ANN 近似最近邻索引(如HNSW、IVF-PQ)。
在线阶段(必须快):
- 用同一个模型把用户
query编码成向量; - 在 ANN 索引里查
top-k(比如 50 个)候选; - 可选:用重排模型(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 的核心增益。
六、工程上的几个坑,我自己踩过的
实践里真正消耗时间的往往不是算法,而是这些细节:
- 查询和文档必须用同一个模型(甚至同一个版本)。拿 A 模型的向量去查 B 模型建的库,结果完全是噪声,而且报错信息不会告诉你这件事。
- 归一化与度量要配套。要么统一
normalize + IP,要么明确用L2,别混着来。 - 切分策略直接影响效果。经验值是
200~500字一块、10~20%重叠;表格、代码这类结构化内容最好单独处理。切得太碎会丢上下文,切得太大语义会糊掉。 - 别忘了 rerank。双塔模型(
bi-encoder)为了快,query 和 doc 是各算各的向量;而cross-encoder会把两段文本拼起来过一遍模型,精度高得多但很慢。常见做法是"向量召回 50 条 + cross-encoder 精排 5 条"。 - 提前算内存和延迟预算。向量存储约为
条数 × 维度 × 4 字节,千万条768维float32就是约30 GB,这时就要上PQ量化或降维。 - 用 0 、 1 、 2 评估,不要凭"感觉搜得挺准"。构造几百条标注的
query → 相关文档对,改一次配置测一次,才知道改动是不是真的有效。
提示:如果检索效果始终上不去,先检查语料质量和切分,再考虑换更大的模型。很多时候是文档本身就写得含糊,向量再强也救不回来。
七、小结与下一步
回到最初的问题:Embedding 是把"意思"变成语义空间中的坐标,语义检索就是在这个空间里做最近邻查找。它的三个支点是——好的向量(对比学习训练出的模型)、合理的度量(归一化 + 内积)、高效的索引(HNSW 这类 ANN 结构)。理解了这三件事,剩下的都是工程细节。
值得继续深入的方向:混合检索(BM25 + 稠密向量 + RRF 融合)、RAG 中检索质量对生成质量的决定性影响、Matryoshka 这类"一个向量多种精度"的可变维表示,以及针对自己领域用少量标注数据做微调——通常这是性价比最高的提升手段。