AI 笔记 · 2026 年 11 月 11 日

用 Embedding 做语义搜索:从原理到可运行代码

关键词搜不到「多久能收到发票」,因为原文写的是「二十四小时内发送」。这篇把向量检索拆成归一化、内积、切片和阈值四件事,并给出一份能直接跑的代码。

用户搜「多久能收到发票」,文档里写的是「电子发票会在订单完成后二十四小时内发送到预留邮箱」。字面没有重合,关键词搜索返回空,向量检索却能命中。

但「能把相近语义排前面」只是起点。真正决定这套东西能不能用的,是归一化有没有做、切片切在哪、阈值定多少,以及模型换了之后索引怎么办。

向量检索在算什么#

一句话:把文本映射成高维向量,语义相近的文本在空间里距离更近。检索就是找最近的 k 个。

工程上有几个必须确认的点:

  • 必须同一个模型。查询和文档用不同模型编码,向量空间不共享,结果接近随机。换模型等于重建索引。
  • 先归一化,再用内积。余弦相似度和内积在向量归一化后等价,而内积可以用一次矩阵乘法批量算完,快得多。
  • 维度不是越高越好。维度高存储和计算都更贵,很多模型支持输出降维向量,损失很小,大规模时值得开。
  • 相似度没有绝对意义。零点八是高是低,取决于模型和语料,只能靠标注样本校准。

最小可运行版本#

先不要上向量库。几千条文档,一次矩阵乘法就够了,而且你能看清每一步在做什么。

python
"""semantic_search.py —— 最小语义搜索:numpy + OpenAI 兼容 embeddings 接口
依赖:pip install numpy openai;设置 OPENAI_BASE_URL / OPENAI_API_KEY"""
import os
import numpy as np
from openai import OpenAI

client = OpenAI(base_url=os.getenv("OPENAI_BASE_URL"), api_key=os.getenv("OPENAI_API_KEY"))
EMBED_MODEL = os.getenv("EMBED_MODEL", "your-embedding-model")

DOCS = [
    "退货需要在签收后七天内发起,商品需保持完好。",
    "电子发票会在订单完成后二十四小时内发送到预留邮箱。",
    "企业客户可以申请月结,需要提供营业执照复印件。",
    "配送范围覆盖全国大部分地区,偏远地区顺延一至两天。",
]

def embed(texts):
    resp = client.embeddings.create(model=EMBED_MODEL, input=texts)
    vecs = np.asarray([d.embedding for d in resp.data], dtype=np.float32)
    # 归一化之后,内积就等于余弦相似度
    return vecs / np.linalg.norm(vecs, axis=1, keepdims=True)

DOC_VECS = embed(DOCS)

def search(query, k=2):
    scores = (embed([query]) @ DOC_VECS.T)[0]   # 一次矩阵乘法 = 全量检索
    top = np.argsort(-scores)[:k]
    return [(DOCS[i], float(scores[i])) for i in top]

for text, score in search("多久能收到发票"):
    print(f"{score:.3f}  {text}")

四行文档、一次乘法,跑通之后再谈索引。注意 embed 里的归一化:漏掉它,内积会偏向长文本,因为长文本的向量模长通常更大,排序会系统性跑偏。

规模决定索引方案#

文档量上去之后,暴力检索的耗时线性增长,这时才需要近似最近邻(ANN)。它用一点召回率换数量级的延迟下降。

文档量级 方案 说明
一万以内 暴力内积 几十毫秒,无需索引,调试最方便
十万级 HNSW 内存索引,查询快,构建慢,参数少
百万级以上 IVF / 量化 需要训练聚类中心,召回率要单独评估
十亿级 分片 + 磁盘索引 属于系统工程,别自己写

还有一条容易被忽略:过滤条件要下推到检索。先取一千条再按租户过滤,等于把召回率交给运气。支持带过滤的索引,或者按租户分库,都比事后筛靠谱。

阈值比模型更影响体感#

向量检索永远会返回结果,哪怕全都无关。用户看到「为您找到」下面挂着四条不着边际的文档,比看到「没有找到」更糟。

所以要定一个相似度阈值,低于它直接返回空。阈值不能拍脑袋,拿五十条「应该没有答案」的查询和五十条「应该有答案」的,画一下两组的分数分布,取一个误杀和漏召都可接受的切点。

评测同样要落到数字:标注每条查询的正确文档,算 recall@k(前 k 条里有没有正确文档)和 mrr(正确文档排第几)。只靠人工看几条结果,很容易把「排序变了」当成「效果好」。

还有一条分工建议:把「检索对不对」和「答案对不对」分开评。召回对了但答案错,是生成环节的问题;召回本身就错了,再怎么改 prompt 也没用。两件事混在一次讨论里,结论一定是「再调调看」。

成本上,文档向量只需算一次,可以离线批量;查询向量每次请求都要算,得控制在百毫秒级,重复查询可以直接缓存。

切片是隐形地基#

一份三千字的手册整篇编码,向量会被平均掉:它同时讲了退货、发票和物流,结果和谁都不够像。切片长度通常取两百到四百字,以自然段为最小单位,不要把一张表格拆到两半。

更实用的做法是「小块检索、大块生成」:用两百字的块建索引保证粒度,命中之后把它的父段落或整节取出来喂给模型。召回精度和生成上下文两边都照顾到了。

每个块还要带元数据:来源、标题路径、更新时间、可见范围。检索时能过滤,回答时能给出处,排查时能追溯。

四类它做不好的查询#

语义模型不是全能的,这四类失败很稳定:

  • 否定。「不支持货到付款」和「支持货到付款」向量非常接近,模型抓不住那个「不」。
  • 数字与型号。「SO-123456」和「SO-123465」在向量空间里几乎同一点,但业务上完全不同。
  • 专有名词。内部代号、人名、新词,模型没见过,编码出来的向量没有区分度。
  • 超长查询。把整段需求描述当查询,核心意图被稀释,反而不如提取关键词。

对应的补法是混合检索:向量负责语义,BM25 负责字面,两路结果用倒数排名融合。专有名词和编号交给 BM25,语义改写交给向量,两边都覆盖不到的地方,才轮到查询改写。

提示

如果你的语料更新频繁(工单、新闻),别指望每天全量重建索引。按时间分片、只重建变化的分片,或者用支持增量写入的索引,比换模型更能解决「搜不到昨天的内容」。

结论#

  • 查询和文档必须用同一个 embedding 模型,换模型就重建索引。
  • 归一化后再用内积,漏掉这步排序会系统性地偏向长文本。
  • 一万条以内先用暴力检索,规模上来了再换 HNSW 或 IVF,别过早引入复杂度。
  • 阈值和 recall@k 要用标注样本定,向量检索永远会返回结果,这正是它危险的地方。
  • 否定、编号、专有名词靠混合检索补,不要指望向量模型自己学会。

完

#Embedding#语义搜索#向量检索#Python
回到顶部 ↑