2024 年我做一个企业内部文档的语义搜索:员工用自然语言提问,从几千篇 wiki、制度文档里找出最相关的几篇。第一版我做得很省事:embedding 模型,我在网上找了个下载量很高的——心想下载量高,总差不了;把每篇文档 encode 成一个向量存进向量库,查询时把问题也 encode 成向量,算相似度取 top 5。本地拿几个问题一测,效果还行。我心里很踏实:"embedding 嘛,不就是把文本转成向量,选个模型、调个 API 就完事了。"可等它真正上线、面对员工五花八门的真实提问,问题一个接一个。第一种:中文问题搜出来的结果全是不相关的——我后来才发现,那个下载量很高的模型,训练语料以英文为主,中文支持很弱。第二种:有同事反馈"搜出来的顺序很怪,明明最该排第一的文档排在了第三"——我的相似度算错了:模型输出的向量没归一化,我却拿裸点积当余弦相似度用。第三种:几千字的长制度文档怎么都搜不到——模型 max_tokens 只有 512,我把整篇直接 encode,超出的部分被默默截断了,文档后半部分等于没进索引。最崩溃的一次:我想换一个更好的中文模型,换上去之后,整个向量库的检索全乱套了——新模型 768 维、旧向量 384 维,根本没法比。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"embedding 就是把文本转成向量,选个模型调个 API 就行"。这句话把 embedding 当成了一个无关紧要的、随便选选的工具。可它不是。embedding 模型的选型,直接决定了你整个语义检索质量的天花板——模型对不对语种、对不对领域、向量多少维、要不要归一化、长文本怎么切、查询和文档用不用同一个模型,每一个都会让召回质量天差地别。真正的 embedding 工程,核心不是"调通一个 encode 接口",而是把选型、归一化、分块、重建这些决定召回质量的环节,一个一个做对。这篇文章就把 embedding 模型选型梳理一遍:为什么"随便选个模型"召回就是不准、选型该看哪些维度、距离度量和归一化为什么决定排序对错、长文本该怎么分块、为什么换模型等于整个向量库作废,以及批量编码、查询缓存、查询与文档对称这些把语义检索真正做对要避开的坑。
问题背景
先把那次语义搜索翻车的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一个用"随手选的高下载量模型"搭起来的文档语义搜索,上线后接连出事:中文问题召回全不相关;结果排序诡异,最相关的文档排在后面;长文档怎么都搜不到;想换个更好的模型,一换整个向量库的检索全乱。
我当时的错误认知:"embedding 就是把文本转成向量,选个模型、调个 API 就完事了。"
真相:embedding 模型是语义检索质量的天花板。它必须被认真选型:模型的训练语种要和你的语料对得上、向量维度和向量库强绑定、向量要归一化后才能用点积算余弦、长文本要分块绕开 max_tokens 截断、查询和文档必须用同一个模型编码、换模型意味着整个向量库全量重建。embedding 不是一个"调通就行"的接口,选错一个环节,后面的检索再精巧也救不回来。
要把 embedding 这件事做对,需要几块认知:
- 为什么"随便选个模型"召回就是不准——模型对不对语种领域是天花板;
- 模型选型——维度、语种、领域、评测榜单到底怎么看;
- 距离度量与归一化——为什么没归一化相似度排序全错;
- 长文本分块——超过 max_tokens 的文档会被默默截断;
- 向量与模型强绑定、批量编码、查询对称这些工程坑怎么处理。
一、为什么"随便选个模型"召回就是不准
先把这件最根本的事钉死:语义检索的本质,是把"文本的意思"压缩成一个向量,再用向量之间的距离来近似"意思的远近";而这个压缩做得好不好,完全取决于 embedding 模型——模型没见过你的语种、你的领域,它压出来的向量就是一团各向同性的噪声,你后面的检索逻辑写得再漂亮,也是在噪声里捞针。
下面这段代码,就是我那个"上线即翻车"的第一版——它每一个环节都埋了雷:
from sentence_transformers import SentenceTransformer
import numpy as np
# 反面教材:随手找了个"下载量高"的模型,压根没看它的训练语种
model = SentenceTransformer("some-english-heavy-model")
def index_docs(docs: list) -> dict:
# 直接把整篇文档 encode 成一个向量,超长部分被模型默默截断
return {d["id"]: model.encode(d["text"]) for d in docs}
def search(query: str, index: dict, top_k: int = 5) -> list:
q_vec = model.encode(query)
scored = []
for doc_id, d_vec in index.items():
# 破绽:直接拿裸点积当相似度,可向量没归一化,点积 != 余弦
score = float(np.dot(q_vec, d_vec))
scored.append((doc_id, score))
scored.sort(key=lambda x: x[1], reverse=True)
return scored[:top_k]
# 破绽 1:模型以英文语料为主,中文查询根本编码不准
# 破绽 2:整篇 encode,超过 max_tokens 的部分被悄悄丢掉
# 破绽 3:裸点积当余弦,向量长度不一时排序全错
这段代码没有任何语法错误,在我本地挑出来的几个测试问题上也跑得像模像样。它的问题不在代码本身,而在一个根本性的轻视:它默认"embedding 模型是个标准件,随便挑一个,encode 出来的向量都差不多能用"。可 embedding 模型根本不是标准件——它是整个语义检索里最不可替换、最需要认真选的一环。于是三个破绽逐一爆发:破绽 1——模型以英文语料为主,它对中文的语义理解很粗糙,中文查询 encode 出来的向量区分度极低,自然搜不准。破绽 2——整篇文档直接 encode,模型 max_tokens 一过就默默截断,长文档的后半段根本没进向量。破绽 3——拿裸点积当相似度,向量没归一化,长度更大的向量仅仅因为长就拿到高分,排序失真。问题的根子清楚了:embedding 不是一个调用,而是一连串需要认真做对的选型和处理。
二、模型选型:别凭下载量,看语种、维度和领域
纠正第一版,第一步就是重新选模型。选 embedding 模型,不能只看"下载量高不高、名字响不响",要看几个实打实的维度:它的训练语料是什么语种(中文检索就得用中文或多语模型)、它输出的向量是多少维(维度越高表达力越强,但存储和计算成本也越高)、它擅长什么领域(通用语料训练的模型,在法律、医疗这类专业领域可能很弱)。
# 选型时该看的几个维度,而不是只盯着"下载量"
EMBEDDING_MODELS = {
"bge-small-zh": {"dim": 512, "lang": "zh", "max_tokens": 512},
"bge-large-zh": {"dim": 1024, "lang": "zh", "max_tokens": 512},
"m3e-base": {"dim": 768, "lang": "zh", "max_tokens": 512},
"multilingual": {"dim": 768, "lang": "multi", "max_tokens": 512},
}
def pick_model(corpus_lang: str, prefer_quality: bool) -> str:
"""按语料语种和质量要求选模型 —— 而不是凭下载量拍脑袋。"""
candidates = [name for name, m in EMBEDDING_MODELS.items()
if m["lang"] in (corpus_lang, "multi")]
if not candidates:
raise ValueError(f"没有适配 {corpus_lang} 语种的 embedding 模型")
# 质量优先就选维度大的(表达力强);否则选小的,省存储省算力
candidates.sort(key=lambda n: EMBEDDING_MODELS[n]["dim"],
reverse=prefer_quality)
return candidates[0]
这个 pick_model 和第一版"随手抓一个"的差别,是它把选型变成了一件有依据的事。它做的第一个、也是最重要的判断,就是语种过滤——lang 不匹配的模型直接被排除。这正是冲着破绽 1 去的:一个英文模型,无论它下载量多高,对中文语料就是不合格。第二个判断是维度与成本的权衡:dim 越大,向量能容纳的语义信息越多、检索质量通常越好,但存储翻倍、相似度计算也更慢。所以是选 bge-large 还是 bge-small,取决于你更在乎质量还是更在乎成本。还有一个选型时必须查清的字段——max_tokens:它是模型能接受的最大输入长度,这个数字直接决定了第四节你要怎么切分长文档。当然,真正选型时,除了这些硬指标,还应该参考公开的中文检索评测榜单,并用自己的真实文档和真实问题跑一遍小规模测试——别人的榜单分数,代替不了你自己语料上的实测。模型选对了,但还有一个第一版就埋下的雷没拆:相似度,到底该怎么算?
三、距离度量与归一化:为什么你的排序是错的
第一版的破绽 3——"排序诡异"——根子在这里:衡量两段文本的语义有多接近,看的应该是它们向量的"方向",而不是"长度"。两个向量方向越一致,意思越接近;而向量的长度(模长),往往只反映文本长短之类的无关因素。余弦相似度,量的正是方向;而裸点积,会被长度污染。要让点积也只反映方向,就得先把向量归一化成单位长度:
import numpy as np
def normalize(vec: np.ndarray) -> np.ndarray:
"""把向量归一化成单位长度 —— 归一化后,点积就等于余弦相似度。"""
norm = np.linalg.norm(vec)
if norm == 0:
return vec # 零向量没有方向,原样返回
return vec / norm
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
"""显式的余弦相似度:不管向量有没有提前归一化,都算得对。"""
denom = np.linalg.norm(a) * np.linalg.norm(b)
if denom == 0:
return 0.0
return float(np.dot(a, b) / denom)
光讲定义还不够直观,看一个具体的例子,就明白第一版的排序错在哪了:
# 演示:三个向量,query 和 doc_a 方向完全一致,只是长度不同
v_query = np.array([1.0, 0.0])
v_doc_a = np.array([3.0, 0.0]) # 和 query 同方向,但长度是 3
v_doc_b = np.array([0.7, 0.7]) # 和 query 夹角 45 度
# 错误做法:直接裸点积 —— doc_a 仅仅因为"更长"就拿了高分
print(np.dot(v_query, v_doc_a)) # 3.0
print(np.dot(v_query, v_doc_b)) # 0.7
# 正确做法:余弦相似度 —— 只看方向,doc_a 才是真正最相关的
print(cosine_similarity(v_query, v_doc_a)) # 1.0 完全同方向
print(cosine_similarity(v_query, v_doc_b)) # 0.707 夹角 45 度
这个例子把问题摊开了。v_doc_a 和 v_query 方向完全一致——它就是语义上最该排第一的。用余弦相似度算,它得 1.0,稳稳第一,完全正确。可一旦用裸点积,你会发现一个更危险的情况:假如 v_doc_b 的长度很大(比如它是一篇很长的文档),它的点积分数可能反超 v_doc_a——于是一篇仅仅因为长的、其实没那么相关的文档,被错排到了前面。这就是同事反馈的"顺序很怪"。解法有两个,本质一样:要么用 cosine_similarity 显式地算余弦;要么——更高效的做法——在所有向量入库前就全部 normalize 一遍,之后查询向量也归一化,这时点积就严格等于余弦相似度,既算得对又算得快。记住:向量进库前先归一化,这一步几乎是零成本的,但能让你后面所有的相似度计算都建立在正确的基础上。度量的事理顺了,接下来是长文档那个坑。
四、长文本分块:绕开 max_tokens 的默默截断
第一版的破绽 2——长文档搜不到——是 embedding 里最隐蔽的一个坑,因为它不报错。每个 embedding 模型都有一个 max_tokens 上限(常见是 512),你喂给它的文本一旦超过这个长度,它不会抛异常,而是默默把超出的部分截掉,只对前半段编码。于是一篇几千字的制度文档,真正进了向量的可能只有开头一小段,后面全丢了。解法是分块(chunking):把长文档切成若干个小块,每块都控制在模型上限之内,分别编码。
def chunk_text(text: str, max_chars: int = 500, overlap: int = 50) -> list:
"""把长文档切成带重叠的小块 —— 每一块都在模型 max_tokens 之内。"""
if len(text) <= max_chars:
return [text] # 本来就短,不用切
chunks = []
start = 0
while start < len(text):
end = start + max_chars
chunks.append(text[start:end])
# 关键:相邻块之间留一段重叠,避免把一句话从中间切断、
# 导致跨越切口的那句话语义在两块里都不完整
start = end - overlap
return chunks
切块之后,一篇文档就不再对应一个向量,而是对应一组向量——每个块一个。所以入库的逻辑也要跟着改:
def embed_document(doc: dict, model) -> list:
"""一篇文档 -- 切成多块 -- 每块一条 (chunk_id, 向量) 记录。"""
records = []
for i, chunk in enumerate(chunk_text(doc["text"])):
# 每一块单独编码,并且【入库前就归一化】(见第三节)
vec = normalize(model.encode(chunk))
records.append({
"doc_id": doc["id"], # 它属于哪篇文档
"chunk_id": f"{doc['id']}#{i}", # 这一块的唯一标识
"text": chunk, # 块的原文,用于结果展示
"vector": vec,
})
return records
这两段代码合起来,才真正解决了长文档的问题。chunk_text 里有个容易被忽略、却很关键的参数——overlap(重叠)。为什么相邻块要留一段重叠?因为如果一刀切下去毫不重叠,正好骑在切口上的那句话,就被劈成了两半,前半句在上一块、后半句在下一块,两块里这句话的语义都不完整。留一段重叠,就能保证每一句完整的话,至少在某一个块里是完整的。embed_document 则体现了分块后的数据模型变化:检索的基本单位从"文档"变成了"块"——你召回的是最相关的块,再通过 doc_id 回溯到它属于哪篇文档。块切多大,是个权衡:切太大,一个块里塞了好几个主题,向量的语义就不聚焦;切太小,一句话缺乏上下文,语义也不完整——通常几百字是个合理的起点,再按实测调整。分块讲完了,接下来是一个会让人整个向量库推倒重来的认知。
五、向量与模型强绑定:换模型等于重建整个库
我那次"换个更好的模型,检索全乱套"的崩溃,根子是一个必须刻进脑子的事实:一个向量,只有在"生成它的那个模型"的语义空间里才有意义;不同模型生成的向量,哪怕维度凑巧一样,也分属两个完全不相通的空间,彼此没有任何可比性。这意味着——你一旦决定更换 embedding 模型,旧的向量库就整个作废了,必须用新模型把所有文档全量重新编码一遍。
def reindex_all(docs: list, old_model_name: str, new_model_name: str):
"""换 embedding 模型 = 整个向量库作废,必须全量重新编码。"""
if old_model_name == new_model_name:
return # 没换模型,无需重建
# 关键:不同模型的向量空间互不相通 —— 哪怕新旧模型维度
# 碰巧一样,旧向量和新模型编码出的查询向量也【没有可比性】。
new_model = SentenceTransformer(new_model_name)
vector_store.drop() # 旧向量必须全部丢弃
for doc in docs:
for record in embed_document(doc, new_model):
vector_store.insert(record)
# 把"当前库是哪个模型建的"记进元数据 ——
# 下次有人想换模型时,才知道在跟谁比、要不要重建。
vector_store.set_meta("embedding_model", new_model_name)
这段 reindex_all 的核心,是 vector_store.drop() 这一行的决绝——换模型时,旧向量不是"部分更新",而是"全部作废"。这一点违反很多人的直觉:大家会觉得"不就是换个模型嘛,向量库里数据还在,凑合用呗"。不行。新模型编码出的查询向量,和旧模型留下的文档向量,处在两个不同的语义空间,它们之间算出来的相似度是纯粹的噪声——这就是我当时"检索全乱套"的真相。还有最后一行那个极其重要的工程习惯:把 embedding_model 这个信息作为元数据,和向量库绑定记录下来。这样,任何时候你都能查清"当前这个库,到底是哪个模型、哪个版本建的"——这能避免一类极其隐蔽的事故:有人悄悄升级了模型、却忘了重建库,导致查询用新模型、库里是旧向量,检索静默地失准。由此也引出一条更通用的铁律:查询向量和文档向量,必须出自同一个模型——这就是查询与文档的"对称性"。强绑定讲清楚了,最后是几个绕不开的工程坑。
六、工程坑:批量编码、查询缓存与对称性
五块认知之外,还有几个工程坑,不处理就会在生产上栽跟头。坑 1:建库时一定要批量编码,别逐条 encode。给几千、几万篇文档建索引时,如果你写个循环一篇一篇 encode,会慢得无法忍受。embedding 模型(尤其在 GPU 上)一次处理一批文本的效率,远高于一条一条处理——批量编码能充分利用并行:
def embed_in_batches(texts: list, model, batch_size: int = 64) -> list:
"""批量编码:一次喂一批给模型,比逐条 encode 快几个数量级。"""
vectors = []
for i in range(0, len(texts), batch_size):
batch = texts[i:i + batch_size]
# encode 支持一次传一个 list,内部会做并行,
# 远快于"for 循环里一条一条 encode"
batch_vecs = model.encode(batch, batch_size=batch_size)
vectors.extend(normalize(v) for v in batch_vecs)
return vectors
坑 2:高频查询的向量值得缓存。用户的提问有大量重复——同一个热门问题,可能一天被问几百次。每次都把它重新 encode 一遍,是纯粹的浪费。给查询向量加一层缓存,能省下可观的计算:
import hashlib
_query_cache = {}
def embed_query_cached(query: str, model) -> np.ndarray:
"""查询向量加缓存:相同的问题,不必反复 encode。"""
# 归一化文本(去空格、转小写)再做 key,让"近似相同"的问题命中
key = hashlib.md5(query.strip().lower().encode()).hexdigest()
if key in _query_cache:
return _query_cache[key]
vec = normalize(model.encode(query)) # 注意:同样要归一化
_query_cache[key] = vec
return vec
坑 3:查询和文档必须用同一个模型编码。这是第五节那条铁律的日常版本:你建库用的是 A 模型,查询时就绝不能图方便用 B 模型——两边向量不在一个空间,算出来的相似度没有意义。坑 4:别忽略 query 和 document 的"非对称"场景。有些模型(尤其检索专用模型)会要求给查询和文档分别加不同的前缀指令(比如查询前面加一句"为这个问题检索相关段落")——用这类模型时,务必照它的文档要求做,否则召回质量会打折。坑 5:embedding 只负责"粗召回",精排可以再加一层。embedding 检索快,但精度有限。对质量要求高的场景,常见做法是两段式:先用 embedding 从全库快速召回 top 50,再用一个更重、更准的 rerank(重排)模型对这 50 条精细打分,取最终 top 5。粗召回保证快,精排保证准。下面这张图,把一次完整的"建库 + 查询"流程串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| embedding 模型选型 | 模型的语种领域和维度直接决定语义检索质量的天花板,别凭下载量选 |
| 语种匹配 | 中文语料必须用中文或多语模型,英文为主的模型编码中文很不准 |
| 向量维度 | 模型固定输出某个维度,维度高表达力强但成本也高,选定后别中途换 |
| 向量归一化 | 把向量缩成单位长度,归一化后点积才严格等于余弦相似度 |
| 余弦相似度 | 只看向量方向不看长度,衡量语义相关性该用它而不是裸点积 |
| max_tokens 截断 | 模型有输入长度上限,超长文本被默默截断,后半段等于没进索引 |
| 文本分块 chunking | 长文档切成带重叠的小块分别编码,重叠避免把句子从中间切断 |
| 模型与向量强绑定 | 不同模型向量空间互不相通,换模型必须全量重新编码整个库 |
| 查询文档对称 | 查询和文档必须用同一个模型编码,跨模型的向量没有可比性 |
| 批量编码与精排 | 建库批量编码快几个数量级,粗召回后可再加 rerank 模型精排 |
避坑清单
- 别凭下载量选 embedding 模型,先看它的训练语种和你的语料对不对得上。
- 中文检索要用中文或多语 embedding 模型,英文为主的模型编码中文很不准。
- 向量维度由模型决定,维度和向量库绑定,选型时就定下来别中途换。
- 算语义相似度要用余弦相似度,别直接拿裸点积,向量长度会污染排序。
- 用点积前先把向量归一化成单位长度,归一化后点积才严格等于余弦。
- 模型有 max_tokens 上限,长文档整篇 encode 会被默默截断,丢掉后半段。
- 长文档要先切成带重叠的小块再分别编码,重叠避免把句子从中间切断。
- 换 embedding 模型等于整个向量库作废,必须全量重新编码,别复用旧向量。
- 查询和文档必须用同一个模型编码,跨模型的向量没有可比性。
- 建库时批量编码,逐条 encode 慢几个数量级,高频查询向量可以加缓存。
总结
回头看那次"语义搜索中文搜不准、排序诡异、长文档搜不到、一换模型全乱套"的事故,以及我后来在 embedding 上接连踩的坑,最该记住的不是某一个模型的名字,而是我动手前那个想当然的判断——"embedding 就是把文本转成向量,选个模型调个 API 就行"。这句话错在它把 embedding 模型当成了一个可以随便替换的标准件。我以为语义检索的难点在后面的检索算法上,embedding 不过是前面那个不起眼的数据转换步骤。可事实恰恰相反:embedding 模型就是整个语义检索质量的天花板。它把文本压缩成向量的那一刻,你能检索到的语义精度上限就已经定死了——后面的检索逻辑,再精巧也只是在这个上限之下打转,救不回一个选错的模型。embedding 这件事想清楚的,正是这个:它表面上是个"把文本变成向量"的技术步骤,本质上是在为你的整个检索系统,选定一块决定上限的地基。
所以做 embedding,真正的工程量不在"调通一个 encode 接口"上。那一行 model.encode(),任何教程的第一页就教完了。真正的工程量,在于你要把每一个决定召回质量的环节都认真做对:你要像选地基一样,按语种、领域、维度去选模型,而不是凭下载量;你要懂得语义的远近藏在向量的方向里,所以归一化、用余弦;你要知道模型有 max_tokens 这道暗门,所以把长文档切成带重叠的块;你要清楚向量和模型是死死绑在一起的,所以换模型就得全量重建、查询和文档必须同源。这篇文章的几节,其实就是顺着这条思路展开的:先想清楚"随便选个模型"为什么召回就是不准,再用语种维度领域把模型选对,用归一化和余弦把相似度算对,用分块绕开长文本的截断,用全量重建守住向量与模型的绑定,最后是批量编码、查询缓存、精排这几个把语义检索做扎实的工程细节。
你会发现,embedding 的思路,和现实里怎么把一屋子书按内容归类上架完全相通。embedding 模型,就是那个读书、给书定位置的图书管理员。一个只懂英文的管理员,来管一屋子中文书,他读不懂书的内容,只能胡乱上架——你之后怎么找都找不对(这是语种不匹配)。一本几百页的厚书,管理员只翻了前十页就决定它该放哪,这本书后面九成的内容就等于没被归类(这是 max_tokens 截断)。你判断两本书是不是讲一回事,该看的是它们的主题,而不是谁更厚(这是余弦相似度,而非裸点积)。最关键的是——你换了一位管理员,新管理员有自己一套全新的分类法,那么旧管理员排好的整个书架就全乱了,你必须请新管理员把所有书重新归类上架一遍(这是换模型就得全量重建)。一个图书馆好不好查,从来不取决于你检索台的界面多漂亮,而取决于当初那位管理员,有没有真的读懂每一本书、把它放对地方。
最后想说,embedding 选得对不对,差距永远不会在演示时的那几个问题上暴露——你精心挑几个简单、规整的问题,什么模型大概都能答上来,你会觉得"embedding 嘛,随便选选就行"。它只在真实的、用户用各种口语化的中文提问、文档又长又杂、业务还会想着升级模型的生产环境里才显形。那时候它会用最难堪的方式给你结账:做不好,你会像我一样,看着中文问题召回一堆驴唇不对马嘴的结果,看着最该排第一的文档诡异地沉在后面,看着长文档因为后半段从没进过索引而永远搜不到;而做对了,无论用户的提问多口语、多刁钻,你的语义检索都能稳稳地把真正相关的那几段捞到最前面,长文档每一段都可被检索,哪天要升级模型,你也清楚地知道要把整个库重建一遍。所以别等召回质量烂成事故才回头查模型,在你写下第一行 encode 之前就该想清楚:我的语料是什么语种、什么领域?这个模型配得上吗?我的文档会不会超长?我的相似度算对了吗?这几个问题都有了答案,你的 embedding 才不只是一个"看起来把文本转成向量了"的步骤,而是一块真正撑得起高质量语义检索的坚实地基。
—— 别看了 · 2026