做检索、做推荐、做聚类、做去重,这几年我接触过的项目里,十有八九绕不开 Embedding。它听起来像个玄乎的词,实际做的事很朴素:把一段文字、一张图、一段音频,压成一串浮点数,让机器能拿它算距离、比相似。很多人第一次接触它是在搭知识库问答的时候,被"向量化""余弦相似度""召回率"这些词劝退;也有人已经能跑通 Demo,但一上真实数据就发现召回结果飘、成本压不住、更新一次要重算全库。这篇东西就是写给这两类人的:前半段把 Embedding 的原理和选型逻辑讲透,后半段给一条能直接抄的落地链路和一份踩坑清单。不需要你是算法出身,会一点 Python、能读懂 JSON 就够;如果你已经在做,也可以直接跳到第三、四节看工程细节。
1. 把 Embedding 拆开看:它到底在解决什么问题
1.1 从"编号"到"坐标":为什么离散符号撑不起语义
早年间做文本匹配,最直接的做法是给每个词编个号,或者用独热编码,一个词一个位置,词表多大向量就多长。这套做法在关键词精确匹配上没问题,但它有个致命缺陷:任意两个词之间的距离都是一样的。把"番茄"编成第 103 号,"西红柿"编成第 5021 号,在机器眼里它们毫无关系,可人一眼就知道这俩基本是一回事。同义词、近义表达、上下位关系,全都丢失了。
Embedding 干的事,是把这种离散编号换成连续空间里的坐标。每个词、每句话、每张图,都落在空间里的某个点上,语义接近的东西落得近,语义远的东西落得远。这个转换的价值在于:距离变成了可计算的量。一旦有了距离,搜索就变成了"找最近的若干个点",聚类就变成了"找抱团的点群",分类就变成了"看它离哪个中心更近"。原本需要写一堆 if-else 规则才能表达的判断,变成了一个数学运算。
我习惯用一个类比来解释:把每段文本想象成地图上的一个位置。传统关键词匹配相当于只认门牌号,必须一字不差;Embedding 相当于给了经纬度,哪怕你说的路名和数据库里写的不一样,只要地点是同一个,坐标就挨得很近。这个类比也顺带说明了一个常见误解——Embedding 不是"更聪明的关键词匹配",它压根不看你用了什么词,它看的是整体语义落在哪。
1.2 向量之间的距离,到底在度量什么
搞清楚"距离"这个词很关键,因为不同距离度量对应的几何假设不一样,用错了会直接影响结果。
余弦相似度衡量的是两个向量的夹角,只关心方向,不关心长度。文本向量通常会对长度敏感——长文档的向量模长容易出现波动,所以大多数文本场景默认用余弦,先把向量归一化成长度为 1,这时候余弦相似度和点积就等价了,计算也更省。欧氏距离衡量的是直线距离,对模长敏感,适合那些"向量长度本身携带信息"的场景,比如某些图像特征。内积则常见于推荐场景,因为模型训练时就是按内积设计的,用户向量和物品向量的内积直接对应打分。
这里有个实操中极易出错的点:训练时用什么度量,检索时就得用什么度量。有些模型是在归一化后的向量上训练的,你在入库时忘了归一化,或者用了欧氏距离去查,结果看着"能跑",但排序质量会掉一截,而且这种掉法很隐蔽——不报错,只是前排结果里混进一些莫名其妙的东西。我的做法是固定一条规则:文本向量统一做 L2 归一化,统一用内积检索,把"度量方式"这件事在代码里写死,不给自己留选择余地。
还有一个常被忽略的问题:相似度分数的绝对值没有意义,只有相对排序有意义。经常有人问我"余弦相似度 0.72 算不算匹配",这个问题没法回答,因为不同模型、不同领域的分数分布完全不同。有的模型随便两句话都能到 0.8,有的模型同一段话换个说法就掉到 0.5。所以阈值不能靠拍脑袋定,得在自己的数据上跑一批正负样本,看分布的重叠区在哪,再定。这一点我在第三节会给出具体做法。
1.3 一张表看清主流方案的能力边界
选型的时候最容易犯的错是"哪个榜单分高用哪个"。榜单的分是在特定数据集上测的,跟你的业务数据往往差着十万八千里。真正该看的是这几类方案各自的性格,下面这张表是我自己整理的经验对照,分数是相对感受,不是精确指标。
| 方案类型 | 语义理解 | 长文本表现 | 多语言 | 推理成本 | 适合场景 |
|---|---|---|---|---|---|
| 词袋 / TF-IDF 类 | 弱,只看词面 | 好,天然支持 | 差 | 极低 | 关键词精确检索、术语匹配 |
| 通用句向量模型 | 强 | 中等,超长要切分 | 中到强(看模型) | 中等 | 语义检索、聚类、去重 |
| 检索专用双塔模型 | 强,偏召回 | 中等 | 看训练语料 | 中等 | 大规模候选召回 |
| 大模型生成的向量 | 很强,常识丰富 | 较好 | 强 | 高 | 小数据量、高精度要求 |
| 行业垂类微调模型 | 强(域内) | 看切分策略 | 一般 | 中等 | 法律、医疗、工单等专业域 |
表格里最值得说的一句是最后一行。通用模型在通用语料上表现很好,但一旦进到专业领域——比如一堆充满内部缩写的工单、满是型号参数的设备手册——通用模型的语义空间就"不认识"这些词,它会把两个完全不同的型号判成相似,因为字面结构像。这时候微调或者换垂类模型带来的提升,往往比换一个更大的通用模型明显得多。我见过一个工单去重的项目,换模型折腾了两周没起色,最后只是在领域数据上做了对比学习微调,重复率识别准确率直接上了一个台阶。
2. 选型:不同场景该押注哪一类 Embedding
2.1 通用文本检索:双塔仍是性价比最高的底盘
如果只做一件事——把一堆文档变成可语义搜索的库——那双塔结构(两路编码器分别编码查询和文档)依然是最稳的选择。原因不在精度,而在可预计算。文档侧可以离线全部算完存进索引,线上只需要算查询这一个向量,然后做一次近邻搜索。这个特性决定了大库场景下的延迟上限,几十万到上千万条文档,响应时间都能压在几十毫秒量级。
对比之下,交叉编码器(把查询和文档拼在一起送进模型打分)精度更高,但它没法预计算,每个候选都要跑一次模型,只能用于重排阶段的小候选集。所以实际工程里常见的组合是:双塔召回 Top 50 到 Top 100,交叉编码器重排取 Top 5。这个"召回宽、重排窄"的漏斗结构,是我见过最通用的做法,几乎不需要针对业务做改造。
选双塔模型时,我更关注三件事而不是总榜排名:一是训练目标是否偏检索,有些模型是拿来做句子相似度判断的,喂给它长度差异很大的两个文本(一个短查询、一个长段落)时表现会打折;二是最大输入长度,太短的模型逼着你把文档切得很碎,碎到语义不完整;三是是否提供了归一化后的输出,没有的话记得自己在代码里补上。
2.2 长文本与多语言:两个最容易被低估的坑
长文本这件事,很多人以为只要模型支持长上下文就万事大吉。实际不是。模型能吞下 8000 个 token,不代表它能把 8000 个 token 的信息均匀地压进一个向量里。实测下来,一个向量承载的信息量是有上限的,文本越长,向量越像所有主题的平均值,细节被稀释得厉害。你拿一个 5000 字的文档算出一个向量,然后用一句具体的查询去检索,很可能查不到,因为那一句的语义在整个向量的占比太小了。
正确的做法不是找更长的模型,而是切块 + 多向量。一段长文切成若干语义完整的块,每块一个向量,检索时命中任意一块就能定位回原文。这也是为什么第三节我把分块策略单独拎出来讲——它比选哪个模型更影响最终效果。
多语言是另一个坑。有些模型号称支持上百种语言,但实际是把各语言映射到同一个空间里,代价是每种语言的细分语义都被压缩了。如果你的库里有中英混排的技术文档,或者有大量中英夹杂的产品名,务必拿真实数据测一遍跨语言检索:用中文查询去捞英文文档,看看排在前面的到底是不是对的。我遇到过一次,模型对英文的支持确实好,但对中文的短查询几乎没反应,最后是给查询侧做了个轻量的翻译扩展才勉强能用。所以选型阶段一定要拿自己语言的真实 query 测,别信宣传页。
2.3 自建还是调用:把账算明白
这个问题本质上是三笔账:算力账、数据账、运维账。
算力账很好算。假设有 100 万条文档,每条平均 300 字,用一个小型句向量模型(参数量在千万级),单卡推理大概每秒能处理几百到上千条,全量跑一遍在小时级。之后每次查询只算一个向量,成本可以忽略。如果改用大模型接口生成向量,成本按 token 计,全量重建一次就是实打实的开销,而且查询侧每次都要走网络调用,延迟和稳定性都不在自己手里。
数据账更关键。很多业务数据是不能外发的,哪怕只是发出去算个向量也不行——向量本身也携带语义信息,理论上可以反推。这类场景没什么好纠结的,只能自建。
运维账是最容易被低估的。自建意味着你要管模型版本、管显存、管批量推理的队列、管模型更新后的全量重建。一个团队如果没有稳定的推理服务能力,自建的隐性成本会远超预期。我的经验判断是:数据量在十万级以内、查询量不大、且对数据外发没有硬约束,直接用成熟接口是最省事的;数据量大、查询频繁、或者有合规要求,老老实实自建一套推理服务,长期看更划算。
2.4 维度、量化与存储成本的换算
维度这件事很多人不敏感,但它直接决定存储和检索成本。一个 768 维的单精度浮点向量是 3KB 左右,100 万条就是 3GB;换成 1024 维就是 4GB。看着不多,但向量库还要建索引,图索引的额外开销通常是原始数据的 1.5 到 2 倍,再加上副本,实际占用可能是账面数字的三四倍。
降维和量化是两个常用手段。量化把单精度浮点压成 8 位整数,存储直接降到四分之一,检索速度也更快,代价是精度有损失——通常在召回率上会掉一到三个百分点,具体多少得测。降维则要小心,主成分分析这类方法能压掉冗余维度,但也会带走一部分区分度,尤其是那些"只在少数维度上有差异"的细分领域,降维后可能彻底分不开。
我自己的取舍原则是:先用原维度把效果跑通、把评估基线固定下来,再去试量化,量化前后在同一批测试集上对比召回率,掉得在可接受范围内就用。反过来做——先量化再调效果——会让你分不清到底是模型不行还是量化导致的,排查起来非常痛苦。
3. 实战:从零搭一个可复现的语义检索链路
3.1 环境与依赖准备
这一节给一条能跑起来的链路,思路是尽量少依赖,把关键环节都暴露出来,方便你替换成自己的模型和索引库。核心依赖就三个:数值计算、向量模型、以及一个近邻检索实现。数据量小的时候用 numpy 暴力算完全够用,十万级以上再考虑专门的索引库。
pip install numpy scikit-learn sentence-transformers代码里的模型路径用一个占位符表示,你换成自己手里任意一个句向量模型即可,接口是一致的:输入一批文本,输出一批向量。
import numpy as np from sentence_transformers import SentenceTransformer MODEL_PATH = "your-embedding-model" # 替换为本地模型目录或仓库标识 def load_encoder(path: str = MODEL_PATH): model = SentenceTransformer(path) return model def encode_texts(model, texts, batch_size: int = 64): """统一在这里做归一化,避免调用方忘记""" vecs = model.encode( texts, batch_size=batch_size, normalize_embeddings=True, # 关键:统一归一化 show_progress_bar=False, ) return np.asarray(vecs, dtype=np.float32)这里把归一化写进封装函数,是我踩过坑之后的强制习惯。团队里只要超过两个人写调用代码,就一定有人忘记归一化,然后出现"某条链路检索结果就是比别人差"的诡异问题,排查半天才发现是漏了一行。把约定固化进函数,比写文档管用。
如果模型不支持normalize_embeddings参数,就手动除一下模长,注意处理零向量的边界情况:
def l2_normalize(vecs: np.ndarray, eps: float = 1e-12) -> np.ndarray: norms = np.linalg.norm(vecs, axis=1, keepdims=True) norms = np.maximum(norms, eps) return vecs / norms3.2 分块策略:决定上限的一步
分块是整个链路里最不"性感"但影响最大的一步。模型选错了顶多掉几个点,分块切错了可能直接让某些问题永远查不到。
先说几个反例。按固定字数硬切,会把一句话从中间劈开,切出来的块前半句没主语、后半句没谓语,向量自然糊。按段落切,遇到那种一整段几百字不带换行的文档,又退化成超长块。按标题层级切是更好的思路,但要处理没有标题结构的纯文本。
我的默认策略是递归切分 + 重叠:优先按段落边界切,段落太长再按句子切,句子还长才按字符切;每个块保留 10% 到 20% 的重叠,防止关键信息正好落在切口上。块大小按模型的最大输入长度来定,一般控制在模型上限的 60% 到 80%,因为太满会把注意力摊薄。
import re def split_by_sentence(text: str): parts = re.split(r"(?<=[。!?!?\.])\s*", text) return [p.strip() for p in parts if p.strip()] def recursive_chunk(text: str, max_chars: int = 400, overlap: int = 60): """按段落 -> 句子 -> 字符 三级回退切分,带重叠""" paragraphs = [p.strip() for p in text.split("\n") if p.strip()] chunks, buf = [], "" def flush(buf): if not buf: return "" chunks.append(buf) # 保留尾部作为下一块的开头,形成重叠 return buf[-overlap:] if overlap > 0 else "" for para in paragraphs: if len(para) > max_chars: buf = flush(buf) for sent in split_by_sentence(para): if len(buf) + len(sent) <= max_chars: buf += sent else: buf = flush(buf) + sent continue if len(buf) + len(para) + 1 <= max_chars: buf = (buf + "\n" + para) if buf else para else: buf = flush(buf) + para flush(buf) return chunks这段代码不长,但有几个实际考量值得说。第一,重叠不是越多越好,重叠太大等于同一份内容被反复索引,检索时会出现多个高度相似的块占满结果位,反而挤掉了别的信息,我一般控制在 10% 到 15%。第二,块要带上元数据——来源文档、章节标题、在原文中的位置——否则检索命中了也没法还原上下文,做引用溯源时会非常难受。第三,如果文档有明显的结构(手册、制度、说明书),优先用结构切,比任何启发式规则都准。
注意:分块大小不是拍一次就完事的。换模型、换文档类型都要重新调,我一般会在评估集上同时试 200、400、800 三档,看召回率的变化,再定下来。
3.3 向量化与索引构建
有了块,接下来批量算向量、建索引。数据量在十万级以内,直接存成 numpy 数组,检索时做一次矩阵乘法就行,简单可靠,还省掉了维护外部服务的成本。
import json, os import numpy as np def build_index(model, chunks, meta_path="meta.jsonl", vec_path="vectors.npy"): texts = [c["text"] for c in chunks] vecs = encode_texts(model, texts) with open(meta_path, "w", encoding="utf-8") as f: for c in chunks: f.write(json.dumps(c, ensure_ascii=False) + "\n") np.save(vec_path, vecs) return vecs def load_index(meta_path="meta.jsonl", vec_path="vectors.npy"): vecs = np.load(vec_path) with open(meta_path, encoding="utf-8") as f: metas = [json.loads(line) for line in f] assert len(metas) == len(vecs), "元数据与向量数量不一致" return vecs, metas那个assert不是凑数的。元数据和向量必须严格一一对应,一旦错位,检索出来的内容会张冠李戴,而且这种错误在 Demo 阶段几乎发现不了,等到线上被用户指出来就尴尬了。构建时最好再存一份"分块配置"(块大小、重叠、模型标识)到索引目录里,将来排查问题时能确认这份索引到底是哪套参数产出的。
搜索侧就是一次内积运算加排序:
def search(query, model, vecs, metas, top_k=10): q = encode_texts(model, [query])[0] scores = vecs @ q # 已归一化,内积即余弦 idx = np.argsort(-scores)[:top_k] return [{"score": float(scores[i]), **metas[i]} for i in idx]百万级以内,这套暴力检索在单机上跑,单次查询在几十毫秒量级,完全能支撑内部工具。超过这个量级再换成带近似索引的向量库,接口逻辑不变,只是把search换掉。
3.4 召回、重排与阈值调参
召回之后要不要重排,取决于你对精度的要求。如果只是内部检索工具,Top 10 里有一条能用就行,那到召回为止就够了。如果是问答场景,把召回内容直接喂给后续模型,前排混进一条不相关的,就可能把答案带偏,这时候重排很值。
重排的常规做法是把查询和候选块拼起来,用交叉编码器打分,取前几条。候选数一般给 30 到 100,太少会漏,太多会拖慢。实测下来,重排能把前三条的相关率提升得相当明显,尤其是在查询比较短、语义比较模糊的时候——这类查询光靠单个向量很难表达清楚意图,交叉编码器能看到查询和文档的逐字交互,判断更准。
阈值这块,别用固定数字。我的做法是跑一批人工标注的样本,把正样本对和负样本对的相似度分数分别画出来,看两条分布曲线的重叠区,把阈值定在重叠区的中间偏保守一侧。保守的意思是宁可多召回一些让重排去筛,也不要因为阈值太高把正确结果卡掉。
| 环节 | 常用参数 | 调整方向 | 观测指标 |
|---|---|---|---|
| 分块 | 400 字 / 60 重叠 | 块太大稀释语义,太小切断上下文 | 召回率、块平均长度 |
| 召回 | Top 50 到 100 | 加大提升召回上限,代价是延迟 | 召回率 |
| 重排 | 取前 5 条入下游 | 候选越多越准,越慢 | 前三条相关率 |
| 阈值 | 依分布定,不拍脑袋 | 偏高漏召,偏低噪声多 | 精确率、人工抽检 |
3.5 效果评估:别用眼睛验收
我见过太多项目是"随便搜几个词看看结果还行"就上线了。这种验收方式有两个问题:一是你搜的往往是"好搜"的查询,真正难的那些你想不到;二是没有基线,改完之后说不清是变好还是变坏。
最低成本的评估方法是构造一个小评估集:从真实查询日志里抽 50 到 100 条,人工标注每条对应的正确文档块,然后算两个指标——命中率(正确结果在不在 Top K 里)和排序质量(正确结果排在第几位)。这两个数一旦有了,任何改动都能立刻对比。
def evaluate(search_fn, eval_set, k=10): """eval_set: [{"query": str, "gold_ids": [str, ...]}, ...]""" hit, rr = 0, 0.0 for item in eval_set: results = search_fn(item["query"], top_k=k) ids = [r["chunk_id"] for r in results] gold = set(item["gold_ids"]) found = [i for i, cid in enumerate(ids) if cid in gold] if found: hit += 1 rr += 1.0 / (found[0] + 1) return {"hit_rate": hit / len(eval_set), "mrr": rr / len(eval_set)}hit_rate看的是"能不能找到",mrr看的是"找到的排得靠不靠前"。两个指标要一起看:只追命中率,可能把排序搞得很乱;只看排序,可能漏掉一批该召回的内容。我在实际项目里会额外再加一列"无结果率"——查询返回空或全部低于阈值的比例,这个数异常高,通常说明分块或阈值配置出了问题,比看平均分更容易发现故障。
4. 常见问题与排查实录
4.1 检索答非所问的六种成因
"搜出来的东西完全不相关"是最常见的反馈,但成因差别很大,得分开排查。下面这张表是我这几年总结的对照,按发生频率从高到低排。
| 现象 | 最可能成因 | 快速验证方式 |
|---|---|---|
| 结果字面像但语义不相关 | 模型与领域不匹配 | 换一批同义改写的查询看结果是否稳定 |
| 明明有答案却搜不到 | 分块把答案切断了 | 把块调大或加重叠后重测 |
| 前排全是重复内容 | 重叠过大或文档本身重复 | 统计 Top 10 的来源文档分布 |
| 短查询效果差、长查询正常 | 查询侧向量信息量不足 | 给查询加标题或上下文再编码 |
| 分数普遍偏低、分布集中 | 漏了归一化或度量方式不一致 | 检查向量模长是否接近 1 |
| 中英混排文档检索不到 | 模型跨语言能力不足 | 用中文查英文文档实测 |
第一条和第四条值得展开。领域不匹配的典型表现是:模型对通用词汇很敏感,但对你的行业术语一无所知,于是把所有术语都当成"相似的陌生词",召回结果就变成了一堆字面结构类似、实际毫无关系的内容。这种情况下换更大的通用模型收益有限,做领域微调才是正路。
短查询的问题更普遍。用户输入"报错 500",这几个字本身携带的语义非常少,编码出来的向量落在空间里一个很模糊的位置。常用的缓解手段是给查询补充上下文——把上一轮对话、用户所在模块、页面标题一起拼进去再编码;或者用后续模型先做一次查询改写,把口语化的短查询扩写成一句完整的描述,再拿去检索。这个改动通常比调模型参数见效快。
4.2 性能与存储的典型瓶颈
向量检索的性能问题,八成出在三个地方:全量重建、单条推理、以及索引没预热。
全量重建是最容易忽略的。文档一更新就整个库重算向量,一次跑几个小时,更新频率一高就顶不住。正确的做法是按块做增量:块内容没变就复用旧向量,只算新增和修改过的块,最后合并索引。实现上给每个块算一个内容哈希,跟旧索引比对即可,改动不大但收益极高。
单条推理指的是把一批待编码文本拆成一条条送进模型,白白浪费了批处理的吞吐。批量推理和逐条推理的吞吐差距能到几十倍,这点在查询侧影响不大(一次就一条),但在离线构建阶段是天壤之别。注意批大小不要贪大,显存吃满之后会触发频繁换页,反而更慢,通常从 32 或 64 起步,逐步往上试,找到吞吐曲线的拐点。
索引没预热是个玄学问题。有些索引结构在服务刚启动时,第一次查询会特别慢,因为需要加载或构建内存中的部分结构。线上服务记得在健康检查里加一次"假查询",把冷启动的代价提前消耗掉,否则第一波真实流量全打在最慢的时刻。
4.3 增量更新与版本管理
向量库和普通数据库最大的不同是:模型换了,所有向量都作废。这个特性决定了必须有版本管理,否则会出现新旧向量混在同一个索引里、彼此不可比的情况——查出来的结果乱得一塌糊涂,而代码里看不出任何异常。
我的做法是给索引目录打上三样东西:模型标识、分块配置、构建时间。查询时只认当前生效的那一个版本,新版本构建完、验证通过了再做切换,切换动作是一次原子替换,出问题能秒回滚。向量库本身支持按分区存的话,就把不同版本放在不同分区里,切版本就是切分区名。
增量更新还有个细节容易踩:删除。文档删了,对应的块和向量也得删,但很多近似索引的删除是"标记删除",物理空间不会立即释放,时间长了索引会膨胀,检索效率下降。所以要定期做一次合并整理,把已删除的部分真正清掉。这件事没人提醒就会一直拖着,等到检索明显变慢才想起来。
注意:任何一次模型或分块参数的变更,都要在评估集上跑一遍完整对比再上线。我见过一次"顺手升级了模型",各项参数都没动,结果某些查询的排序全变了,原因就是新模型的分数分布和旧的差异很大,原来定的阈值不再适用。
5. 工程化:把 Demo 变成能长期跑的服务
5.1 归一化、批处理与显存控制
这三个词看着琐碎,但线上事故里它们的出场率极高。归一化前面说过,这里补一个排查技巧:写个自检函数,随机抽 100 条向量,检查模长是否都在 1 附近,偏差异常就报错。这个检查放进构建流程里,成本几乎为零,能拦住一大类隐蔽问题。
批处理要注意长度分布。同一批里如果混着 20 字和 2000 字的文本,模型会按最长的那条补齐,短文本白白浪费时间在填充上。按长度排序后再分批,或者做长度分桶,能把吞吐提升不少。这在离线构建阶段是实打实的加速。
显存控制的核心是别让进程无限增长。推理服务长时间运行,张量缓存和碎片会慢慢堆积,常见做法是限制单次批大小、定期重启工作进程、或者用固定的内存池。另外记得把模型加载和服务启动分开——加载模型可能要几十秒,如果健康检查在这期间就开始放流量进来,请求会全部超时失败。
5.2 缓存与降级
查询向量的缓存收益比想象中大。真实流量里重复查询的比例很高,尤其是热门问题,缓存住"查询文本到向量"的映射,能省掉相当一部分推理。命中率通常能到两三成,长尾场景更高。缓存键用原文加模型版本,模型一换键就变,天然不会脏读。
降级策略要提前设计。向量模型服务挂了,整个检索链路就瘫了,这时候如果有个兜底的关键词检索,至少能保证基本可用。我一般会保留一份轻量的倒排索引,语义检索失败时切过去,虽然效果差一些,但比直接报错强得多。同理,重排服务超时的时候要有超时熔断,直接返回召回结果,别让整个请求卡在那里。
5.3 监控指标与灰度
线上要盯的指标,我按重要性排:无结果率、平均延迟、Top K 的分数分布、以及用户反馈。无结果率突然飙升,多半是索引没加载完或者阈值配错了;延迟抖动通常是批处理或资源竞争的问题;分数分布的整体偏移特别有诊断价值——均值突然上移或下移,往往意味着模型版本变了或者数据分布变了。
灰度这件事在向量检索里容易被跳过,因为觉得"只是个搜索"。但模型或分块策略一改,结果排序是整体变化的,用户的感知非常直接。稳妥的做法是切一小部分流量到新索引,对比一段时间内的点击或采纳情况,指标不明显变差再全量。这个流程比反复回滚省事得多。
最后分享一个我自己的判断标准:一个 Embedding 检索系统是否健康,不看它能不能搜出漂亮的结果,而看它出错的时候你能不能在两小时内定位到原因。为此我坚持两件事——所有构建参数写进索引元数据,所有评估结果留档。这两件事在项目顺利的时候毫无存在感,出问题的时候能救命。真要说有什么体会,那就是这套东西的难点从来不在模型本身,而在那些"看起来不重要所以没人管"的细节上:归一化漏了一行、分块边界切歪了、索引版本混着用。把这些地方一个个钉死,效果自然就稳了。