用户搜「多久能收到发票」,文档里写的是「电子发票会在订单完成后二十四小时内发送到预留邮箱」。字面没有重合,关键词搜索返回空,向量检索却能命中。
但「能把相近语义排前面」只是起点。真正决定这套东西能不能用的,是归一化有没有做、切片切在哪、阈值定多少,以及模型换了之后索引怎么办。
向量检索在算什么#
一句话:把文本映射成高维向量,语义相近的文本在空间里距离更近。检索就是找最近的 k 个。
工程上有几个必须确认的点:
- 必须同一个模型。查询和文档用不同模型编码,向量空间不共享,结果接近随机。换模型等于重建索引。
- 先归一化,再用内积。余弦相似度和内积在向量归一化后等价,而内积可以用一次矩阵乘法批量算完,快得多。
- 维度不是越高越好。维度高存储和计算都更贵,很多模型支持输出降维向量,损失很小,大规模时值得开。
- 相似度没有绝对意义。零点八是高是低,取决于模型和语料,只能靠标注样本校准。
最小可运行版本#
先不要上向量库。几千条文档,一次矩阵乘法就够了,而且你能看清每一步在做什么。
"""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要用标注样本定,向量检索永远会返回结果,这正是它危险的地方。 - 否定、编号、专有名词靠混合检索补,不要指望向量模型自己学会。
完