Embedding:把文字变成向量

从关键词匹配到语义搜索

上一章我们聊透了模型的记忆边界,结论是把知识外置成可检索的库。但检索这个动作本身,其实也有新旧两种玩法,差别大到像两个时代。

传统做法是关键词匹配。用户输入请假流程,系统就在文档里找包含请假和流程这两个词的段落,谁词频高谁排前。这套逻辑在图书馆系统里跑了几十年,简单可靠,但碰上真实用户的提问就露馅。用户问的是新人入职第一年有几天假,文档里写的是服务满十二个月可享受带薪年休假五日。一个字都对不上,传统检索直接零结果,可明明是同一个意思。

再往深看一步,关键词匹配的命门在于它只认识字面,不认识字面背后的需求。用户嘴上说假,文档里写的是休假,中间差着一整个同义词库。你可以人工维护同义词表,但中文里同一件事的表达方式有几十种,维护到天荒地老也覆盖不全。更别提还有澄清、反问、带条件的问句,关键词系统对这些通通束手无策。

人类看一眼就知道这两句话说的是一回事,机器怎么看出来的?靠的就是 embedding,把文字变成一串数字,让意思相近的句子在这串数字上靠得近。这竖数字学名叫向量,整个过程叫向量化。自从有了它,检索从比对字符串升级成了比对意思,这就是语义搜索。

明白这一步跨界的意义,你才能理解为什么知识库非要配个向量数据库不可——传统数据库存的是字符,字符之间没有距离这个概念,再怎么加索引也做不到语义层面的查找。

Embedding 到底干了什么

先有个整体印象:embedding 模型读进一段文字,吐出一串定长的浮点数。比如最经典的 OpenAI 那套 text-embedding-3-small 输出 1536 维向量,nomic-embed-text 输出 768 维。输入可长可短,输出长度永远固定。这串数字就是这段文字的语义指纹。

它怎么把意思变成数字的?原理不神秘。embedding 模型本身也是个神经网络,训练时喂进去海量文本对,用的就是你在 ol-0301 里见过的那类权重,只不过训练目标完全不同。它学的是让意思相近的文本对算出来的向量距离小,意思无关的文本对距离大。见过了几亿个请假和年假其实是一回事的例子,模型就把这种关系固化进了自己的权重。

打一个不太严谨但好用的比方。想象每个向量都是地图上的一个坐标点,意思就是经纬度。请假和休假落在同一片街区,报销和开票在另一片,相隔很远。用户提问时,系统把问题也放到这张地图上,看看谁家离得最近,就近的几家就是检索结果。这张地图不是人类画的,是模型从语料里自己学出来的,所以它认识的概念远多于任何人工分类体系能设计的。

回头看开头的例子。新人入职第一年有几天假经过 embedding 变成向量甲,服务满十二个月可享受带薪年休假五日变成向量乙。因为两句话经常在语料里一起出现、表达的需求相通,它们的向量在空间里贴得很近。用户提问的向量往库里一撒,按距离排序,乙排在前几名,这就是 semantic 相似度的全貌。

有三个细节值得先记牢,后面调优时天天用:第一,向量只对语义敏感,对措辞不敏感,换个同义词距离几乎不变;第二,长度固定意味着可以放进统一的数据库索引里做快速比对,这是工程上能落地的前提;第三,向量之间的距离是可以用余弦相似度算出个具体分数的,比如零点八七,检索时threshold定在多少直接决定召回率。

维度、距离和余弦相似度

向量有两个绕不开的概念:维度和距离。维度就是那串数字有多长,768 维就是 768 个浮点数排成一列。外行常觉得维度越多理解越深,其实不完全对——维度高意味着信息容量大,但也意味着计算更贵、存储更占地方。1536 维向量存一条将近 6KB,一百万条就是 6GB,上项目前这笔账得算明白。

顺带一提,维度里真正管事的往往是前十几个方向,专业术语叫主成分。一个训练良好的 embedding 空间里,头几个维度可能正好对应最粗的语义分类,比如公文与闲聊、积极与消极这些最大颗粒的差别。掌握了这个直觉,碰到一千多维的向量也不用怵,它在数学上和一张很宽的 Excel 表没有本质区别,只是每个格子装的小数没人看得懂而已。

距离衡量两段文字有多像,工程里八成用余弦相似度。别被名字吓住,它算的是两个向量方向上的夹角:方向一致得一分,正交是零分,方向相反扣一分。文本嵌入经过训练后基本都在正向空间里扎堆,实际取值常见在零点三到零点九之间。你的检索系统定个阈值,比如只收相似度零点七以上的片段,这个数后面会反复出现。

为什么不用直线距离?因为文本的向量长度其实带着信息含量。一段长文档平均下来每个维度的分量被稀释,模长偏小;一个短句子的向量反而可能模长更大。直接比直线距离,长文档天然处于劣势。余弦只看方向不看模长,把长度差异抹平,比的就是纯粹的语义方向,所以文本检索几乎成了它的天下。

说句题外话,相似度这东西在不同模型之间不能直接比大小。甲模型输出的零点八和乙模型输出的零点八,含义完全不同,因为各自的向量空间是独立训练出来的。换模型必须重新算一遍库里所有文档的向量,旧向量在新模型下没有任何意义。这个坑踩过的人不在少数,把新模型直接往旧库上接,一查一个不相关。

顺带一个常被问到的问题:向量空间里能做四则运算吗?答案是部分可以。女王减去国王加上男人约等于女王对应的语义位置,这类著名的词向量实验展示过。但工程上别指望它做精确推理,它捕捉的是统计意义上的共现关系,拿来当辅助理解可以,当成知识图谱来查就过度解读了。

模型 维度 是否本地可跑 大小 适用场景
nomic-embed-text 768 是,Ollama 直接拉 约 274MB 通用中英文文档检索
bge-m3 1024 是,CPU 也能跑 约 1.2GB 多语言混合文档
text-embedding-3-small 1536 否,需调 API 托管服务 有网络条件的海外项目
mxbai-embed-large 1024 是 约 670MB 英文为主的场景挑刺者
qwen3-embedding 1024 是 约 600MB 中文场景,语义理解更细腻

上手并不难,Ollama 已经内置支持。拉一个 embedding 模型试试:

ollama pull nomic-embed-text

然后跑一条最简调用,亲手把一句话变成向量:

curl http://localhost:11434/api/embeddings -d '{"model":"nomic-embed-text","prompt":"服务满十二个月可享受带薪年休假五日"}'

返回的 JSON 里 embedding 字段就是那 768 个数,你可以复制出来再换一句话算一次,拿 Python 算算两句话的余弦相似度。第一次跑通这个的人都会有点震撼感——意义这种抽象东西,居然真的被压进了可计算的数字里。Python 端的完整调用方式在 ol-0404 里有完整示例,这里先体会概念。

模型选型:中文场景的一些经验

选 embedding 模型有一套朴素但有效的判断流程。第一步看语种,你的文档是纯中文、中英混排还是纯英文,这三类对应的最优解不同。中文为主的库,优先考虑中文厂牌出的 embedding 模型,它们在本地语料上花过更多功夫,词汇分布更贴近实际;纯英文的库选面就宽得多,MTEB 榜单上前排随便挑。

第二步看部署条件。内网环境、海外云服务不可达,那就只能在本地能跑的开源模型里选,这其实是大多数私有知识库的常态。能联网的项目可以混合用,但要做好一旦断线整条链路断气的预案。第三步看规模,十万条以内的文档几乎是是个 embedding 模型都能扛,成本不是问题;到了百万级文档,维度带来的存储和检索开销就开始敏感,选模型时得多留个心眼。

还有个反直觉的经验:embedding 模型和聊天模型最好选同一家族,但不是必须同一家族。同一家族意味着分词器和语义空间对齐得更自然,端到端表现往往更稳;但 embedding 和生成本来就是两个任务,各自选最强的那个通常效果更好,别被绑架。

顺带说说中文场景里具体的体会模型差异。有些通用模型把所有中文文档都映射到相对拥挤的一个区域,向量之间挤在一起区分度不够,结果就是问什么都召回差不多的那几条。你若发现检索结果总在相似度零点七附近一堆挤着,十有八九是模型对中文的区分能力不足,换模型比重调阈值管用。考察一个模型适不适合你,最省事的办法是拿三组相似但不同的描述互算相似度,再看三组分数是否清晰分得开。

最后给一个具体建议:拿你真实的五十条问答对离线评测。把问题向量化,从文档库里检索 top 5,看命中了几条、命中的排在第几名,这比看任何榜单都准。榜单测的是公开语料上的平均表现,你的文档分布天然偏门,平均分高不代表在你这一亩三分地吃得开。评测这件事在后面的 RAG 流程章节还会展开,那里会给出可照抄的评测脚本思路。

工程上的四个小坑

第一个坑是归一化。有的模型输出已经单位化,有的没有。算余弦相似度时如果不统一,同一个库里两段文字的距离不可比,排名结果会鬼畜。多数库如 Chroma 内部会自动处理,但你自己手写计算时千万记得先归一化,只除一次模长的事。

第二个坑是截断。embedding 模型也有输入长度上限,超过就截断,而且截断是硬切,一句话从中间断开语义就残了。所以切片时必须按模型的 token 上限来定每片长度,这个话题在 ol-0304 里细聊。

第三个坑是版本。embedding 模型偶尔会更新版本,新旧版本输出可能不完全一致。库里的向量最好记录一下所用模型名和版本号,将来整体迁移时按版本分批重算,而不是新老混存着用。

第四个坑藏得更深,是语言混杂。同一句话,同一个模型,中英两种表述产生的向量应该在空间里靠得很近,但不少模型的跨语言对齐做得很糟,中问英答的场景下相似度直接掉一截。如果你的企业文档天然是中英混排(技术文档常见),选模型时专门拿中英对来测,bge-m3 这类主打多语言的模型就是为这种场景生的。在 ol-0303 里会比较两个数据库对多语言索引的支持差异。

常见问题

Ollama 的 embeddings 接口和 generate 接口有什么区别

定位完全不同。embeddings 接口只返回向量,不生成任何文字,速度快、占用小,给文档建库时一条条调它。generate 和 chat 接口是给用户对话用的,会真正组织语言回答。建库用 embeddings,问答时才用 chat,两者各司其职,别拿来混用——用 generate 给文档建向量不是不行,是又慢又浪费。

为什么同一段话重复算出来的向量有一两位小数漂移

因为不同处理器浮点精度不同。CPU 和 GPU 的实现路径不一样,同一模型同一输入在两边可能算出零点零几级别的差异,属于正常现象。只要不拿前后两次结果做精确比对判断是否同一文档,这点漂移对余弦相似度的影响在第三位小数以下,可以忽略。但对敏感场景,建议固定部署在同一类硬件上。

向量维度太高导致检索变慢怎么办

先分情况。十万条以下基本感觉不到。文档到了百万级,可以把近似最近邻索引(如 HNSW)打开,牺牲一丁点精度换取数量级的速度提升;也可以考虑降维,用主成分分析之类的方法把 1536 维压到 512 维再存一份,检索用降维版粗筛、原始向量精排,工程上常见。还有一招是量化,把 float32 存成 float16 或 int8,存储直接减半,速度跟着提一截。

embedding 模型和聊天模型到底是不是一回事

不是一回事,而且差着行。聊天模型的输出是一段段文字,目的是把话说对说顺;embedding 模型的输出是一串你看不懂的数字,目的是把语义装进空间位置里。两者的训练目标、损失函数、评价标准全都不同,谁也替代不了谁。常见的误会是用聊天模型给文档建向量,或者指望 embedding 模型回答问题,前者纯属浪费算力,后者直接得不到结果。一条链路里它们各占一段,embedding 出向量去检索,聊天模型拿到结果后组织答案,这在 RAG 流程那篇的拆解里会看得更清楚。

一个向量库里能混存多种文档吗,需要分开建库吗

能混存,但不一定应该混存。如果文档类型差别大(合同、聊天记录、代码注释),它们的语义空间离得远,混在一起检索时彼此干扰少,问题不大。但如果你发现跨类型的误召回明显,比如问合同条款却检索回来闲聊记录,那按类型分集合(collection)会更干净。多数向量数据库支持一个实例下多个集合,成本几乎为零,与其纠结不如一开始就分好。