Embedding:把文字变成向量,以及换模型的代价
约 2362 字大约 8 分钟
2026-09-16
数据侧第三步:向量化。这一篇讲三块——向量到底是个什么东西(讲透一次,受用全程)、批量入库的工程细节(坑最密的地方)、以及换模型的代价(架构级约束)。
本篇对应 relax-admin 的「知识库配置 → AI 引擎」页(模型配置侧)和「知识库 → 知识入库」页(批量向量化任务的观测侧)。
向量到底是什么,一次讲透
Embedding 模型干的事:吃进一段文字,吐出固定长度的一串数字,比如 1024 个小数。这段数字就是文字的「语义指纹」——意思相近的文字,指纹在空间里的位置就相近。
有几个性质,理解了后面的坑都不神秘:
一,向量不可解释。 1024 个维度里,没有任何一维代表「价格」或者「退款」。语义只藏在整串数字的几何关系里(主要是方向)。所以你别想着「手动改某个维度来修正一条知识」——修正只能回到文本层:改段落内容,重新算向量。
二,这是一次有损压缩。 变长的段落被压成固定 1024 维,压掉了什么?压掉了所有模型「没学会区分」的细节。模型没见过的行话、内部代号、冷门缩写,压出来的向量就是不准——这不是 bug,是物理。后面第 09 篇讲混合检索,就是在给这个缺陷打补丁。
三,坐标系属于模型。 这是最重要的一条,单独开一节讲。
换模型为什么必须全量重建
把它当成一个数学事实记住:每个模型有自己独立的语义空间,不同模型的向量互相比较没有意义。
打个比方。A 模型的空间里,「退款」在第 37 街区;B 模型的空间里也有个第 37 街区,但住的是完全不同的一批词。你拿 A 模型给问题算的向量,去 B 模型给语料算的向量堆里找邻居,找到的「邻居」纯属巧合。北京的房子坐标搬到上海的地图上比位置,比出来的距离没有意义。
由此推出几条工程铁律:
- 换 Embedding 模型 = 全量重算重灌,没有任何妥协空间
- 新旧向量绝不能混在一个库里——而且这种错不报异常,就是默默变差,等你发现的时候已经怀疑人生半个月了
- 所以每条向量必须记下「它是谁算的」。我的表里有个
embedding_model字段,将来换模型时按它区分新老数据,灰度切换、随时回滚 - 换模型是贵的,所以上一篇说「切分别乱调参,要靠评测驱动」——每次改切分参数就全量重算,几千个切片的成本是钱,几百万个切片的成本是心疼
最阴险的错
跨模型的向量混库不会报任何异常,症状只是「检索莫名变差」。等你排查的时候,很难第一时间想到是半年前那次换模型埋的雷。
相似度怎么算:一次说清 cosine / 点积 / 欧氏
三个名词,其实一种就够用,但面试和排坑都需要你知道关系。
- 余弦相似度(
cosine):看两个向量的夹角。文本语义检索的事实标准,因为它只看方向不看长度——文字的「语义」藏在方向里,向量长度受文本长度、词频干扰,去掉正好 - 点积(
dot product):夹角乘长度。注意一个关键事实:向量归一化后(长度缩放成 1),点积和余弦完全等价 - 欧氏距离(
L2):直线距离。归一化后,它和余弦的排序结果一致(单调等价)
所以实践里你只要搞清一件事:你的模型输出归一化了没有。多数 API 默认输出已归一化向量,此时选哪个度量都一样;没归一化时用点积会被「长向量」霸榜(某个向量模长特别大,跟谁点积都高),排序就歪了。
PGVector 里对应的算子:<=> 余弦、<#> 内积、<-> 欧氏。老规矩再敲一遍黑板:建索引用的算子和查询用的算子必须配套,混用要么索引白建要么结果错乱。下一篇讲索引的时候会看到怎么配套。
批量入库:坑最密的一段路
单条调用 embeddingModel.embed("一段文字") 很简单,难的是把几千个切片又快又稳又便宜地灌进去。这段路的坑,按出现频率排:
坑一:无脑并发,收获 429。 云端模型服务都有速率限制。一激动开了二十个线程并发算向量,第三秒开始满屏 429(请求太频繁被拒)。解法三件套:
- 固定批大小:一次 API 调用塞多条文本,各家的批量接口一般一次十条起
- 限速:信号量或者简单的每秒请求数上限
- 指数退避重试:被拒了等 1 秒、2 秒、4 秒再试,别头铁立刻重试
坑二:批量中途失败,全量重跑。 一批 2000 条,算到 1500 条挂了,从头再来?浪费的是真金白银。正确做法是切片级记录状态:每个切片有自己的向量化状态(待算/已算/失败),失败了只重跑失败那批。我的表里 chunk 状态机就是这么设计的:解析 → 切分 → 已向量化,一环扣一环,哪环断了从哪环续。
坑三:逐条 insert,慢到怀疑数据库坏了。 向量是 1024 个浮点数,序列化后一条好几 KB,几千条逐条 insert 能磨出一杯咖啡的时间。用批量写入(JDBC batch 或者 PG 的 COPY),速度差一个数量级。
坑四:忘了记模型名。 上面说过了,重申一遍:embedding_model 字段,第一天就要有。
组装起来,这段代码的骨架长这样:
// 伪码,表达结构
List<Chunk> pending = chunkDao.findStatus("CHUNKED", limit);
for (List<Chunk> batch : partition(pending, BATCH_SIZE)) { // 分批
rateLimiter.acquire(); // 限速
try {
List<float[]> vectors = embeddingModel.embedBatch(texts(batch));
vectorStore.write(toDocuments(batch, vectors)); // 批量写入
chunkDao.markStatus(batch, "EMBEDDED");
} catch (RateLimitException e) {
backoffAndRetry(batch); // 退避重试,只重试这批
}
}结构比代码本身重要:分批、限速、可断点续跑、只重试失败子集——这四个词是这个环节的全部灵魂。
这个批量任务的进度和失败重试,落在 relax-admin 的「知识入库」页:整条入库流水(解析 → 切分 → 向量化 → 就绪)按知识库汇总展示,哪一批切片卡在向量化、失败了多少条、能不能一键重试失败的子集——前面说的「chunk 状态机」,前端可见形态就是这一页。
📷 图片位(待补充)
应放什么图:「知识库 → 知识入库」页截图,圈出一次全量向量化任务的进度条和失败子集的「重试」按钮,体现「断点续跑、只重试失败那批」不是纸上谈兵。
成本,顺便算笔账
Embedding 按 token 计费。粗算一下我的博客:两百多篇文章,平均切出 15 个段落,全库 3000 来个切片,平均每个 400 token,一次全量向量化也就一百来万 token。按主流模型的定价,这个数字小到可以忽略——所以个人博客随便折腾,切分参数想调就调。
但把同一个数字乘以一千(企业知识库百万切片的量级),每次调整参数全量重算就是一笔正经的钱了。这就是为什么「评测驱动调参」不只是方法论情怀,是经济学:先攒评测集确认改动有效,再动全量。习惯从博客规模养成,到了大库规模就是救命。
验收:向量算得对不对
写完入库代码,做个冒烟测试再收工:
float[] q = embeddingModel.embed("怎么防止用户重复下单");
float[] d1 = embeddingModel.embed("通过分布式锁保证幂等性");
float[] d2 = embeddingModel.embed("推荐几本好看的小说");
// 手算余弦,q 和 d1 应该明显比 q 和 d2 近两对相似度拉出来一对比,模型和代码就都验过了。这种 30 秒的自检,能帮你排除掉「静悄悄错位」类的问题(比如不小心拿问题的向量去和问题比、文档压根没入库之类)。
今天的落地清单
到这里,「文章变向量」这半程走完了。下一篇把最后一块拼图放上:PGVector。向量住进 Postgres 之后,怎么建索引才能查得又快又准——以及为什么说「近似检索」这四个字里,藏着调参的全部秘密。
下篇见。