前阵子有位做内部知识库问答的朋友找我,说他把生成模型换成了当时最贵的旗舰版,回答质量却一点没提升,有些问题甚至答非所问。我把检索结果调出来一看,Top 10 里有一半跟用户问题毫无关系。这种问题我在 RAG(检索增强生成)项目里见得太多了:多数人遇到效果差,第一反应是换更聪明的生成模型,其实真正的瓶颈往往在检索侧,而检索侧的根基就是嵌入模型。这篇东西就是我对 LangChain 环境下嵌入模型的一次完整梳理——它到底在 RAG 链路里起什么作用、文本是怎么变成向量的、主流的商业模型和开源模型怎么选、怎么科学地判断一个嵌入模型适不适合自己的业务,以及我在生产环境里踩过的那些坑。
1. RAG链路里最容易被低估的一环:嵌入模型
1.1 一次失败的知识库问答排查
先把那次排查的过程说完整。那位朋友的项目是一个企业内部制度问答机器人,文档有几百页,包含考勤、报销、年假、加班等各种制度。他用了当时效果很强的生成模型,但用户反馈经常是"答非所问"。
我当时让他把链路拆开看数据:用户问题转成向量之后,从向量库里召回了什么片段。结果很明显——召回片段跟问题不在一个话题上。比如有人问"年假可以拆成半天休吗",召回的文档片段是"加班调休申请流程"。这种输入给再强的生成模型,它也只能硬着头皮编,回答怎么可能对。
那段时间我越发确定一件事:RAG 项目的效果上限,从文档切分和嵌入那一刻就决定了。生成模型只是在既定的材料里组织语言而已。如果材料本身是错的,输出一定不会好。
1.2 嵌入模型是如何决定召回质量的
RAG 的标准流程并不复杂:先把文档切成片段(chunk),每个片段通过嵌入模型变成一个向量;用户提问时,同样把问题变成向量;然后在向量库里找出跟问题向量最相似的 TopK 个片段,作为上下文喂给生成模型。
所以你可以把嵌入模型理解成一支"翻译部门"——它把人类语言翻译成高维空间里的坐标点。如果这支翻译部门翻译得不够准确,那么"年假能不能拆成半天"和"加班调休流程"这两个句子在向量空间里的距离就会很近,系统就会把后者当成前者最相关的内容。
实际项目里,嵌入模型对召回的影响甚至比切分策略更大。切分策略影响的是"材料粒度",嵌入模型影响的是"语义是否真的被编码进去了"。我见过很多团队花大量时间调切分块大小,却始终用一个通用嵌入模型。换个更合适的嵌入模型,召回命中率提升十几个点是常态。
1.3 两个模型的分工:一个管找材料,一个管写答案
我想给刚接触 RAG 的读者一个清晰的心智模型:RAG 里有两个模型在配合,各管一段。
嵌入模型负责"找到该看哪些材料"。它把用户问题和文档片段放进同一个向量空间,谁离得近就认为谁相关。它的考核指标是召回命中率,看的是"真正有用的片段有没有被捞上来"。
生成模型负责"把这些材料组织成通顺的答案"。它的考核指标是答案的准确性和可读性,但它能发挥到什么程度,完全取决于喂给它的材料质量。巧妇难为无米之炊,放在 RAG 里就是"无柴之炊"。
所以当你发现 RAG 效果不好,先别急着怪生成模型。第一步永远是把召回链路的中间数据拿出来看一遍:召回 TOP5 里有没有正确答案。如果经常不在,问题八成出在嵌入模型或切分策略上。
2. 文本变成向量的"翻译"过程
2.1 嵌入不是关键词匹配,是语义坐标
很多刚接触嵌入的开发者会有一个误区:觉得嵌入模型就是高级的词频统计。其实完全不是一回事。
传统的关键词检索(比如 BM25)是在做字面匹配:你搜"猫打架",文档里没有"打架"这个词,那就认为不相关。但"我家两只猫最近总是互相抓挠"这句话,语义上跟"猫打架"是一回事,字面上却一个词都对不上。
嵌入模型解决的就是这个问题。它把"猫打架"和"两只猫互相抓挠"分别映射成两个向量,因为在训练阶段见过大量类似语义的上下文,这两个向量在空间里的位置会很接近。这就是语义检索能做的原因。
生活化的理解:每个句子经过嵌入模型后,变成了高维空间里的一个点。语义相近的句子,点在空间里的距离就近;语义无关的句子,点就散得很远。用户问一个问题,其实就是往空间里丢一个新点,我们找的就是离这个点最近的几个文档点。
2.2 Transformer模型是怎么读懂句子的
当前主流嵌入模型基本都是基于 Transformer 架构训练出来的。一个大致的原理是:文本先被拆成 token(可以理解成词或子词),每个 token 进入多层注意力网络,模型通过注意力机制判断句子里的哪些词在语义上相互关联。
比如"苹果发布了新款手机"这句话,模型需要学会"苹果"在这里指的是公司而不是水果,这种能力来自海量文本训练中的上下文统计。注意力机制让每个词都能"看到"整句话的其他词,从而生成融合了上下文信息的表示。
在变成最终向量之前,模型通常要在序列维度上做一次池化,比如取最后一层所有 token 向量的平均值或 CLS 位置向量。然后得到的就是一个固定维度的句子向量。这也是为什么嵌入模型输出的向量是定长的——不管输入是 5 个字还是 500 个字,出来的都是一个固定维度(比如 1024 维)的数组。
需要提醒的是,这个多维向量里的每一维并没有"某个维代表情感、某个维代表主题"这种可解释含义。它是一个整体坐标,我们只在数学空间里度量距离,不要试图逐维理解。
2.3 相似度计算的核心套路
向量离得近不近,最常用的度量是余弦相似度。公式不复杂:
similarity = cos(θ) = (A · B) / (|A| × |B|)A 和 B 是两个向量,A · B 是点积,|A| 和 |B| 是欧几里得范数。余弦相似度的值域是 -1 到 1,越接近 1 表示方向越一致,也就是语义越接近。
在工程实现上,有一个非常推荐的做法:把向量先做 L2 归一化,再用点积代替余弦相似度。因为归一化之后 |A| 和 |B| 都等于 1,余弦公式直接化简成了点积。向量数据库和很多框架里都有这个默认约定。如果你在自定义代码里混用了"未归一化的向量"和"直接点积",结果偏差会很大,这个细节我在第 6 章会专门展开。
2.4 维度到底意味着什么
不同嵌入模型的输出维度差别很大,常见的有 384、512、1024、1536、3072。维度越高,理论上承载的信息容量越大,但存储和计算成本随之上升。一个 1536 维的向量在向量库里的空间占用,是 384 维向量的 4 倍,检索时的计算开销也同比例增长。
这里有个重要的工程知识点:OpenAI 的 text-embedding-3 系列支持输出维度收缩(比如 text-embedding-3-small 默认 1536 维,你可以降成 1024 甚至 512),这是通过一种叫 Matryoshka 表征学习的训练方式实现的。简单说,模型训练时就强迫前若干维保留尽可能多的信息,所以你可以从前面截取一部分维度而不至于损失太多语义。
但要注意:降低维度是一定有损的,只是损失幅度相对可控。我的经验是,对大多数知识库问答场景,512 到 1024 维已经够用;如果你做的是细粒度语义匹配或相似文章查重,维度可以给高一些。别一上来就追求最大维度,先算一下向量库的存储增长成本再决定。
3. LangChain里嵌入模型的接入姿势
3.1 Embeddings抽象层是干什么的
LangChain 定义了一个统一的嵌入模型接口,核心就两个方法:embed_documents和embed_query。前者接收一批文本片段(比如切好的 chunk),返回每个片段的向量;后者接收一条用户查询,返回一个向量。
这个抽象的价值在于:你在代码里不需要关心底层是 OpenAI 还是 Hugging Face 模型,只需要面向这个接口写业务逻辑。将来换模型时,改一行配置就够了。
当前 LangChain 的包结构比早期版本清晰了不少。OpenAI 系列在langchain_openai包里,Hugging Face 系列在langchain_huggingface或langchain_community包里。旧代码里那种全部堆在langchain.embeddings下的写法,现在基本已经淘汰了,如果你在看旧教程,注意包路径要对齐自己的版本。
3.2 为什么要把 query 和 document 分开
刚开始用 LangChain 嵌入接口时,我一度不理解为什么要拆两个方法:不都是把文本变成向量吗?后来在实践里才明白,这是为了适配真实业务差异。
用户 query 通常很短,几句话甚至几个关键词;文档片段通常是完整段落,几百字很正常。这个差异会导致统计特征完全不同。而且有一些嵌入模型针对检索场景做了指令优化——比如在查询文本前加上特定的提示前缀(常见的如"Represent this sentence for searching relevant passages"),让模型输出的向量更贴合"查询-文档匹配"任务。
LangChain 把两个方法分开,就是让你能对不同输入类型做差异化处理。大部分封装好的模型,内部默认实现是一样的;但如果你用的是需要加前缀的模型,直接在embed_query里加上即可,embed_documents里保持原样。如果你用同一个模型处理批量文档和 query,加上指令前缀的效果通常比不加更好。
3.3 两个常用集成的代码实测
OpenAI 系列接入最简单,直接配模型名和 key。下面是我在项目里实际跑过的配置:
from langchain_openai import OpenAIEmbeddings embeddings = OpenAIEmbeddings( model="text-embedding-3-small", dimensions=1024, # text-embedding-3 系列支持维度收缩 max_retries=3, request_timeout=30, ) query_vec = embeddings.embed_query("年假可以拆成半天休吗") doc_vecs = embeddings.embed_documents([ "年假申请流程见公司制度第3章...", "加班调休申请需要提前两个工作日提交...", ])OpenAI 的 API 经过无数业务验证,稳定性确实好。但注意max_retries和timeout一定要设置,尤其在批量处理大量文本时,网络抖动是常态,不设重试会让整个流程中断在某个文本上。
开源模型用的是 Hugging Face 封装,代码更贴近本地部署场景:
from langchain_community.embeddings import HuggingFaceEmbeddings embeddings = HuggingFaceEmbeddings( model_name="BAAI/bge-m3", model_kwargs={"device": "cuda"}, encode_kwargs={"normalize_embeddings": True}, ) query_vec = embeddings.embed_query("报销流程需要什么材料") doc_vecs = embeddings.embed_documents([...])这里有两个关键细节:device要显式指定为 cuda 或 cpu,避免模型默认加载到奇怪的位置;normalize_embeddings建议设为 True,配合向量库做点积检索。bge-m3 这类模型在原始论文里也建议对向量做归一化。
3.4 批量嵌入的批大小与速率控制
很多人第一次写批处理时会犯一个错误:把几千个 chunk 一次性塞给embed_documents。这个做法在本地开源模型面前还算能忍,但在 API 模型上会直接触发限流或超时。
批大小的合理范围取决于模型和你的网络状况。OpenAI API 的话,我一般控制在 64 到 128 条一次;本地模型则看显存和推理引擎,比如 bge-m3 在单张 24G 显卡上跑 64 条一批也很轻松。
经验做法是写一个分批循环,并在每批之间做简单退避:
def batch_embed(texts, batch_size=64, sleep_seconds=0.5): vecs = [] for i in range(0, len(texts), batch_size): batch = texts[i:i + batch_size] vecs.extend(embeddings.embed_documents(batch)) time.sleep(sleep_seconds) return vecs对于 API 调用,如果一直遇到 429 限流错误,除了调小批大小和加长等待,还可以检查自己是否开了多线程并发并发量大。并发既能提高吞吐,也更容易撞限流,需要权衡。
3.5 自定义嵌入类的场景
LangChain 的接口设计还有一个好处:接入公司内部自研的嵌入模型或者私有推理服务时,不需要改业务代码,只要实现一个类。
from langchain_core.embeddings import Embeddings from pydantic import BaseModel class MyPrivateEmbeddings(Embeddings, BaseModel): api_url: str = "http://internal-model:8080/embed" api_token: str = "" def embed_documents(self, texts): return [post_embed(text, self.api_url, self.api_token) for text in texts] def embed_query(self, text): return post_embed(text, self.api_url, self.api_token)有了这个自定义类,后面接向量库、写检索链路的代码完全不用动。这是 LangChain 嵌入抽象层最实用的一点。我见过不少团队接自研模型时,直接在检索逻辑里写 HTTP 请求,把代码搅得一团糟,其实没必要。
4. 主流嵌入模型的横向对比与选型矩阵
4.1 商业 API 模型的优劣势
商业 API 模型最大的卖点是省心。你不需要准备 GPU、不需要维护推理服务、不需要关心模型更新。这类模型的代表是 text-embedding-3 系列、Ada-002、Cohere 的 embed 系列等。
效果上,商业模型在通用中英文语义匹配任务上表现稳定,尤其长文档检索场景,质量通常不错。但有两个不可回避的问题:首先是成本,嵌入是按 token 计费的,知识库文档动辄几万条 chunk,一次性嵌入成本还能接受,但后续每次知识库更新都要重新嵌入一遍,积少成多;其次是数据出境问题,如果业务文档包含客户隐私、商业机密,把整库文档发到云端 API 去做向量化,合规层面需要谨慎评估。
我的建议是:数据敏感度低、快速验证产品时,直接用商业 API;数据敏感性高,或者团队本身有 GPU 资源,优先考虑开源模型自部署。
4.2 开源模型的实际表现
开源嵌入模型这几年进步非常快。以中文场景为例,BGE 系列和 M3E 系列是社区讨论最多的两个方向。BGE-M3 最突出的优势是多语言和长文本支持,同时支持稀疏检索和稠密检索两种形式;M3E 则在中文语义匹配上做了大量针对性优化,早期在中文 C-Eval 相关评测里表现不错。
开源模型的好处不只是省 API 费用。最核心的是可以私有化部署,文本不用离开内网。其次,你可以基于业务数据做微调,比如针对法律、医疗等垂直领域的专业术语做领域适配,这是商业 API 很难做到的(即便 API 支持 fine-tuning,流程和成本也很麻烦)。
代价也很明显:你得养一个推理服务,处理并发请求,做好负载均衡。对于只有个位数并发的小团队,用 FastAPI 包一层模型推理接口就够了;并发量大了之后,才需要考虑多副本和显存规划。
4.3 中文场景的特殊性
我必须单独提一下中文嵌入的特殊性。中文的 token 切分逻辑、语义颗粒度和英文差异很大,一个在英文评测上很强的模型,在中文里可能表现平平。另外,中英文混合的文本(比如技术文档)对模型的跨语言能力要求更高。
中文场景里,我见过不少人拿英文模型直接做中文 RAG,然后怪效果差。更合理的做法是选择在中英双语上有专门优化的模型,比如 BGE-M3 这类以多语言见长的模型,或者专门中文语料训练过的模型。判断标准很简单:拿 20 条真实业务问题,把候选模型挨个试一遍,人工看召回结果。这比看任何榜单都管用。
4.4 选型决策矩阵
| 模型 | 主要维度 | 语言侧重 | 部署方式 | 适合场景 |
|---|---|---|---|---|
| text-embedding-3-small | 1536(可收缩) | 多语 | 云端 API | 快速原型、中小知识库、数据不敏感 |
| text-embedding-3-large | 3072(可收缩) | 多语 | 云端 API | 对召回质量要求高、预算充足 |
| BGE-M3 | 1024 | 中英方向好 | 本地/API | 私有化部署、多语言、长文本场景 |
| M3E-large | 1024 | 中文 | 本地/API | 中文为主的知识库语义匹配 |
| Cohere embed-v3 | 1024 | 多语 | 云端 API | 企业级应用、多语言、按量计费 |
这个矩阵不是要替你下结论,而是帮你快速缩小候选范围。选型时问自己四个问题:数据能不能出网?预算有多少?中文占比多高?团队有没有 GPU 运维能力?答案不同,最优解完全不同。
4.5 我的一次选型复盘
去年做一个模拟项目 X 时,我犯过一个典型的选型错误:一开始图省事选了云 API 的嵌入模型,Demo 跑得飞快,但业务方后来要求所有数据处理必须在内网完成。结果整个链路推到重来:换开源模型、部署推理服务、重新向量化全量文档。
那次之后我的流程固定为:先看数据合规要求,再看部署资源,最后才做模型效果对比。顺序不能反。如果一开始就确认必须私有化,直接省掉试商用 API 的时间。
5. 用什么标准判断嵌入模型好不好
5.1 公开榜单只能当参考
我现在看到还有人拿 MTEB、C-MTEB 排行榜直接当选型依据,就想多说两句。公开评测集衡量的是模型在通用语义任务上的能力,覆盖面广,但跟你的业务场景可能是两回事。
举个很常见的例子:一个做法律文书检索的项目,通用榜上分数很高的模型,可能对法言法语的理解并不好;相反,某个榜上排名中游的模型,因为训练语料里法务文本比例高,在你的数据上可能更合适。榜单的意义在于筛选大方向,排除明显不行的模型,而不是直接拍板。
5.2 用业务样本做定量评测
更靠谱的做法是建立一个小型的业务评测集。步骤很简单:
- 从真实用户提问记录里抽出 50 到 100 条问题。
- 人工为每个问题标注 1 到 3 个"正确答案所在文档片段"(gold chunk)。
- 对候选嵌入模型分别跑召回,计算 Top5 / Top10 命中率。
这些工作看起来繁琐,却是选型里性价比最高的一步。我做过的项目里,几乎每次都能发现榜单排名和业务表现的明显落差。自己动手测一遍,比在网上看十篇评测文章都有用。
5.3 一个最小可跑的评测脚本
分享一个我常用的简化版评测脚本,可以直接跑起来:
import numpy as np from langchain_community.embeddings import HuggingFaceEmbeddings model = HuggingFaceEmbeddings( model_name="BAAI/bge-m3", encode_kwargs={"normalize_embeddings": True}, ) questions = ["年假可以拆成半天休吗", "报销发票丢失怎么办"] gold_chunks = ["年假制度第3条", "财务报销管理细则第5条"] # 从知识库里切出来的全部chunk all_chunks = [ "年假制度:员工每年享有10天年假,可拆分为半天申请。", "财务报销管理细则:发票丢失需提交情况说明。", "加班调休:工作日加班可申请调休。", # ...省略 ] chunk_vecs = np.array(model.embed_documents(all_chunks)) hit_counts = 0 for q, gold in zip(questions, gold_chunks): q_vec = np.array(model.embed_query(q)) scores = chunk_vecs @ q_vec.T # 归一化后点积 = 余弦相似度 topk_idx = np.argsort(scores)[::-1][:5] topk_chunks = [all_chunks[i] for i in topk_idx] if any(gold in c or c in gold for c in topk_chunks): hit_counts += 1 print(f"Top5 命中率: {hit_counts / len(questions):.2%}")注意脚本里我用了归一化向量后的点积代替余弦相似度,如果模型输出没有在封装层归一化,记得自己先对向量做 L2 归一化,否则结果不可靠。
5.4 评测中容易被忽略的变量
做评测时最怕的是变量没控制好。我发现最常见的三个问题:
第一个是切分方式不一致。同样是测试召回,一个模型用 200 字的 chunk,另一个模型用 800 字的 chunk,命中率没有可比性。评测之前,先定好统一的切分参数。
第二个是 query 的表述不一致。有的团队测试时把用户口语问题改写成了书面语,效果好是好了,但上线后用户提问依然口语化,效果立刻回落。评测应该直接用真实用户问题。
第三个是只测了一个模型就急着上线。至少对比两三个模型再决定,时间成本往往只需要两三天,却能避免上线后被动换模型。
6. 嵌入模型在生产环境里的常见坑
6.1 归一化和相似度计算方式
前面多次提到归一化,因为这里坑真的很多。有些向量库默认用余弦相似度,内部会自动归一化;有些库的默认距离度量是 L2 距离,两者结果不完全等价。更麻烦的是,有些封装库的normalize_embeddings默认是 False。
我踩过的具体坑是:本地测试时,用 sklearn 的cosine_similarity算相似度没问题,但接向量库后用了默认的欧氏距离检索,结果排序完全不对。后来统一改成"向量先归一化,再用点积检索",结果才对齐。
强烈建议在项目初期就定下一套代码约定:所有落地到向量库的向量,一律先 L2 归一化;检索统一用点积。这样本地调试和线上行为完全一致,少踩很多坑。
6.2 文本长度与截断问题
嵌入模型对输入文本的长度有硬限制。比如很多基于 Transformer 的模型,token 上限是 512 或 8192,超过之后有两种处理:直接截断,或者报错。截断是个隐藏的杀手——一段 1000 字的文档,真正跟用户问题相关的关键句可能在末尾,结果被截掉了。
更隐蔽的情况是:两段文本因为都超长被截断,截断位置不同,导致原本不相似的文本变得相似。我自己就遇到过,chunk 切分时图省事设了很大的 chunk_size,结果检索乱掉,排查半天才发现是截断问题。
解决方案有两个方向:一是在切分阶段就把 chunk 控制在模型安全长度以内,二是超长文本用滑动窗口做二次切分,或者利用摘要信息作为补充。我通常选择前者,因为切分策略本来就是 RAG 必须调好的环节,顺便解决长度限制问题是最经济的。
6.3 向量索引的参数选择
很多团队刚接向量数据库时,直接用默认参数,效果不理想也不知道为什么。索引参数确实值得看两眼。
以 HNSW 图索引为例,它有两个关键参数:M 表示每个节点的连接数,ef_construction 和 ef_search 控制建索引和检索时的候选集大小。M 和 ef 越大,召回通常越好,但内存占用和检索延迟也越高。合适的起点一般是 M 在 16 到 32,ef_search 在 100 左右;数据量上了百万级再往上调。这个参数需要根据你的数据量、硬件和召回目标做几轮实验,不该一直用默认值。
如果数据量不大(几万条),也可以考虑暴力检索(flat index),效果最稳定,省去调参成本。
6.4 缓存策略与成本控制
嵌入计算在 RAG 链路里属于高频成本项。知识点是:文档向量在知识库不更新时,一辈子只需要算一次;用户 query 向量却每次都要算。为 query 做缓存是成本优化的第一优先事项。
我见过的一个实际案例:同一批用户问题在一个内部工具里被反复检索,因为没做缓存,每天要调几万次嵌入 API。加了简单的 query 向量缓存(用哈希或者数据库表),成本直接降到原来的零头。实现方式很简单,在检索入口处先查缓存,命中就直接用,不命中再算。
文档侧的成本控制也有经验:知识库是增量更新的,不必每次全量重新嵌入所有文档,只对新增和变更的 chunk 做增量嵌入就能维持绝大部分正确性。
6.5 模型版本固定问题
最后分享一个最容易被忽视的坑:嵌入模型版本升级之后,同一句话产生的向量会完全改变。这意味着线上向量库里已经存在的向量,和新模型产出的向量,在数学空间里不可比。
我见过一个团队把某个 API 嵌入模型从旧版本升级到新版本,但没有重建向量库,结果线上检索结果全面退化。他们查了很久才发现,旧向量和向量库里的索引还是旧模型算的,新旧混用导致语义空间错位。
解决方案很简单:升级嵌入模型是一个全量工程事件,必须先离线跑一遍全量文档嵌入,验证新向量空间下的召回率,确认没有回落,再切换线上流量。旧向量库保留一份回滚备用,直到新版本稳定观察一段时间后再清理。
从实际项目里走来,我现在做 RAG 已经形成了一套固定节奏:先花一两天时间,用真实业务样本把两三个候选嵌入模型的召回命中率测出来,再决定链路选型。这个习惯帮我避免了很多次"上线后才发现模型选错"的尴尬。嵌入模型不像生成模型那样能直接被用户感知,但它决定了整个检索链路的根基。如果你正在被 RAG 效果差、答非所问、召不回正确内容这些问题困扰,不妨按我上面这套方法,先把嵌入模型这一环重新审视一遍。