Embedding 接口接入与模型选型
Embedding 接口将文本转换为高维浮点向量,是语义搜索、RAG 知识库、推荐系统的核心原语。调用一次 API 即可将一段文本”编码”为向量,再通过余弦相似度或点积找出最近邻文本块。
第一次接 Embedding 的人容易踩一个坑:以为它跟 Chat 接口一样,随便调调就能上生产。实际上 Embedding 出问题往往不是接口本身报错,而是”能跑但检索结果很烂”——你搜”怎么退款”,召回的却是”发货地址怎么改”。这类问题排查起来比 401/429 麻烦得多,因为接口层面一切正常,问题出在分块、维度选择、模型混用这些”看不见”的地方。这篇文章除了给你能直接抄的代码,重点是把这些容易踩的坑讲透。
主流 Embedding 模型对比
| 模型 | 维度 | 最大 token | 特点 |
|---|---|---|---|
| text-embedding-3-small | 1536(可截断) | 8191 | 性价比高,推荐默认 |
| text-embedding-3-large | 3072(可截断) | 8191 | 精度更高,成本约 5× |
| text-embedding-ada-002 | 1536 | 8191 | 旧版,已被 3-small 超越 |
| bge-m3(国产) | 1024 | 8192 | 中英双语强,可本地部署 |
怎么选,给你个实际判断路径而不是空泛建议:如果你的知识库以中文为主、体量在几十万条以内,先上 bge-m3 本地部署试一版,省下按 token 计费的钱;如果你的团队没有本地部署 GPU 资源、或者需要跟 Chat 接口共用同一套调用体系(同一个 Key、同一套计费和监控),直接用 text-embedding-3-small,工程复杂度最低;只有当你验证过 small 版本召回率不够、且这一步是整个系统的瓶颈时,再考虑 large——它贵 5 倍不是白贵的,但大多数场景里,检索效果差是分块策略的问题,不是模型精度不够,先换模型往往是走了弯路。ada-002 现在基本没有理由再选,除非你的历史向量库全是用它生成的、迁移成本太高。
text-embedding-3-* 支持 dimensions 参数截断维度,降低存储成本。这个特性背后的原理叫 Matryoshka Representation Learning(俄罗斯套娃表征学习):训练时刻意让向量的前面若干维承载更多信息量,后面的维度是”精修”,所以简单截断前 N 维(而不是随机采样或降维投影)依然能保留大部分语义信息。这也是为什么截断维度比你自己训练一个低维模型划算得多——精度损失是可控且经过官方验证的,不是玄学。
Python 示例
下面这段代码演示两种调用方式:单条文本和批量文本。这两种方式的区别不只是”传一个字符串还是一个列表”这么简单——批量调用能显著降低网络往返(RTT)开销。假设你要给 1000 段文本做 embedding,逐条调用意味着 1000 次 HTTPS 握手加请求排队,哪怕每次只要 200ms,累计下来也是 3 分钟起步;批量打包成 20 次、每次 50 条,总耗时能压到十几秒。代码里的 dimensions=512 是可选参数,不传就用模型默认的 1536 维,传了就按上面讲的 MRL 原理截断——这里选 512 只是示例,具体截到多少要结合你的检索效果测试来定,不要照抄。
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ["OPENAI_API_KEY"],
base_url=os.environ.get("OPENAI_BASE_URL", "https://api.lidayun.com/v1"),
)
# 单条文本
resp = client.embeddings.create(
model="text-embedding-3-small",
input="大模型 API 的 Embedding 接口怎么用?",
dimensions=512, # 可选:截断维度节省存储
)
vector = resp.data[0].embedding
print(f"维度:{len(vector)}, 首三值:{vector[:3]}")
# 批量文本(一次请求,节省 RTT)
texts = ["第一段文本", "第二段文本", "第三段文本"]
resp = client.embeddings.create(
model="text-embedding-3-small",
input=texts,
)
vectors = [d.embedding for d in resp.data]
print(f"批量:{len(vectors)} 条向量")
批量接口有个隐藏限制很多人第一次踩:一次请求里的所有文本会被合并计算 token 总数,如果某一批里混进了几段特别长的文档(比如没切好的整篇 PDF 内容),单条超过 8191 token 就会导致整个请求报错 context_length_exceeded,而不是只丢弃那一条。排查这种问题的方法是:批量调用前先对每条文本做 token 长度校验(用 tiktoken 库估算),超限的单独截断或跳过,别指望接口帮你兜底。
另一个真实会遇到的报错是 RateLimitError: 429,这在批量灌历史文档做首次建库时很常见——你可能一次性丢几万条文本去跑 embedding,瞬间请求数超过账号的 RPM(每分钟请求数)限额。解决办法不是傻等重试,而是做指数退避加并发限流,下面是一个实用的封装思路:
import time
import random
def embed_with_retry(client, texts, model="text-embedding-3-small", max_retries=5):
for attempt in range(max_retries):
try:
resp = client.embeddings.create(model=model, input=texts)
return [d.embedding for d in resp.data]
except Exception as e:
if "429" not in str(e) or attempt == max_retries - 1:
raise
# 指数退避 + 随机抖动,避免多个批次同时重试造成新的拥堵
wait = (2 ** attempt) + random.uniform(0, 1)
time.sleep(wait)
这段代码的关键不是”重试”本身,而是加了随机抖动(jitter)。如果你有多个进程同时在跑 embedding 任务,纯指数退避会让它们在同一时刻集体重试,反而制造出新的请求尖峰,抖动能把这些重试请求错开。生产环境里再建议加一个全局的并发信号量(比如 asyncio.Semaphore(5)),把同时在途的请求数控制在账号限额以内,从源头上减少 429。
余弦相似度计算
余弦相似度衡量的是两个向量的”方向”是否接近,跟向量的长度(模长)无关,这正是它适合语义检索的原因——两段意思相近但表达繁简不同的文本,向量模长可能差很多,但方向应该是接近的。下面这个函数是最基础的实现:
import numpy as np
def cosine_similarity(a: list[float], b: list[float]) -> float:
a, b = np.array(a), np.array(b)
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
query_vec = client.embeddings.create(
model="text-embedding-3-small",
input="如何降低 token 成本?",
).data[0].embedding
# doc_vectors 已预先计算并存储
scores = [(cosine_similarity(query_vec, dv), i) for i, dv in enumerate(doc_vectors)]
top3 = sorted(scores, reverse=True)[:3]
这段代码在原型阶段没问题,但你会发现它有个性能陷阱:每次查询都要在 Python 里对全部 doc_vectors 做一次线性扫描,向量库超过 10 万条之后,单次查询延迟会明显上升到几百毫秒甚至秒级。这就是为什么生产环境要用 Faiss、Qdrant 这类专门的向量检索引擎——它们内部用 HNSW、IVF 这类近似最近邻(ANN)索引结构,把线性扫描的 O(n) 复杂度降到近似 O(log n),用极小的召回率损失换取几十倍的查询速度提升。手写余弦相似度扫描适合你在本地验证效果、或者数据量在几千条以内的小场景,超过这个规模就该迁移到专门的向量库了。
还有个容易被忽略的细节:如果你提前对存入库的向量做了 L2 归一化(下面”最佳实践”里会讲),那么点积(dot product)和余弦相似度在数值上是完全等价的,而点积的计算量比余弦相似度少一次除法和两次开方,向量库内部几乎都是拿点积当默认距离度量在跑,这也是为什么归一化不是可选项而是性能优化的必要步骤。
Node.js 示例
import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL ?? "https://api.lidayun.com/v1",
});
const resp = await client.embeddings.create({
model: "text-embedding-3-small",
input: ["大模型 API 是什么", "如何接入 OpenAI"],
});
const vectors = resp.data.map((d) => d.embedding);
console.log("维度:", vectors[0].length);
向量存储选型
| 方案 | 适合规模 | 特点 |
|---|---|---|
| Faiss(内存) | < 100 万条 | 纯本地,零运维,适合原型 |
| Chroma | < 500 万条 | 开源,Python 原生,本地/远程 |
| Qdrant | 百万 ~ 亿级 | 高性能,支持 payload 过滤 |
| pgvector(PostgreSQL) | 中等规模 | 复用已有 PG,运维成本低 |
| Pinecone / Weaviate | 大规模云场景 | 全托管,按量付费 |
这张表给的是规模建议,实际选型还要看你团队的运维能力和已有基础设施,给几个具体场景:如果你的业务数据本来就存在 PostgreSQL 里,团队没有专门运维数据库的人力,pgvector 几乎是唯一合理选择——不用多引入一套系统,用同一套备份、权限、监控体系,代价是超过千万级数据量后查询性能会明显不如专用向量库,你需要在那之前规划好迁移路径,别等到扛不住了才想起来换。Faiss 的短板是没有内置的持久化和分布式能力,进程重启向量就没了,除非你自己写落盘逻辑,所以它更适合”跑个实验看看效果”,不建议直接拿去扛生产流量。Qdrant 的优势是原生支持 payload 过滤,也就是你可以一边做向量检索一边按元数据(比如”只在某个分类下搜索”)做精确过滤,这在多租户或者分类检索场景下比自己在应用层做二次过滤效率高得多。
最佳实践
- 分块策略:将长文档切为 200-500 token 的 chunk,重叠 50 token,避免语义截断。
- 批量调用:一次最多传 2048 条,利用批量接口减少请求次数。
- 维度截断:
dimensions=512在大多数场景下与 1536 维效果相差 < 2%,存储节省 66%。 - 归一化:存入向量库前用 L2 归一化,点积即等于余弦相似度,查询更快。
这几条原则说起来简单,但每一条背后都有个”为什么是这个数”的道理,值得展开讲讲。
分块的 200-500 token 不是拍脑袋定的:切得太短(比如 50 token),单个 chunk 携带的上下文太少,向量表达的语义会很单薄,容易检索到字面相关但语境不对的内容;切得太长(比如 2000 token),一个 chunk 里可能同时涵盖好几个话题,向量变成了”平均语义”,反而谁都匹配不精准。200-500 token 大致对应中文 300-800 字,一般是一段完整论述的长度,这是多数 RAG 实践里验证过的甜点区间。重叠 50 token 是为了避免关键信息正好被切在两个 chunk 的边界上——比如一句”退款需要在签收后 7 天内申请”被从中间切开,前半句和后半句分别落在两个 chunk 里,语义都不完整,重叠区域能保证边界附近的句子至少完整出现在一个 chunk 里。
维度截断的”< 2%“这个数字来自 OpenAI 官方公布的评测(MTEB 基准),不同任务、不同语言上实际损失会有波动,你在自己的业务数据上最好还是拿一批标注好的”问题-正确答案”对跑一遍召回率对比,再决定截到多少维——512 维是个常见的性价比选择,但不是万能数字。
还有一条最佳实践清单里没写但同样重要:换模型要重新 embed 全库,不能混用。不同模型(哪怕是同厂商的 3-small 和 3-large)输出的向量空间完全不兼容,你没法拿 3-small 生成的查询向量去跟 3-large 生成的文档向量算余弦相似度——数值上能算出一个结果,但这个结果没有任何意义,检索出来的内容跟查询毫不相关,而且这种错误不会报错,只会让你觉得”这个模型效果好差”,其实是牛头不对马嘴。同理,如果你把 dimensions 参数在库里改了(比如从 1536 改成 512),也必须重新 embed 整个知识库,两次输出不能混着用。
进阶:并发与流式场景
如果你的知识库是持续增长的(比如用户每天上传新文档),建议不要走”全量重新 embed”的老路,而是做增量 embedding:新增文档单独 embed 后 upsert 进向量库,用文档的唯一 ID 做去重和更新标记。这样能把日常的 embedding 调用量从”全库量级”降到”增量量级”,成本能差出几个数量级。
成本这块可以提前算一笔账:以 text-embedding-3-small 为例,假设知识库有 10 万篇文档,平均每篇 800 中文字(约 1200 token),全量 embed 一次大概是 1.2 亿 token。按官方文档上的定价数量级估算(具体单价以官方最新价目为准),全量跑一次的费用通常在几十到一百元人民币这个量级,并不算贵——真正的成本大头往往不是首次建库,而是查询侧的重复调用:如果你的产品有大量重复或高度相似的用户问题(比如客服场景里”怎么退款""退款流程”这种变体),给查询结果加一层缓存(按查询文本做哈希,命中缓存直接返回历史检索结果)能省下不少实时 embedding 调用。
常见问题
Embedding 模型能跨语言检索吗? text-embedding-3-* 支持多语言,中英混合检索效果较好。若以中文为主,bge-m3 通常精度更高。
每次查询都要重新 embed 吗? 查询文本需实时 embed;知识库文档 embed 一次后存入向量库即可,无需重复计算。
Embedding 结果可以缓存吗? 可以。相同文本+模型+dimensions 的输出是确定性的,可按 (text, model, dimensions) 做哈希缓存,显著降低成本。
为什么检索结果总是不相关,接口也没报错? 这是最常见也最难查的问题,按优先级排查:先看是不是模型混用了(查询用的模型跟建库时用的模型或维度不一致);再看分块是否合理(chunk 太长导致语义被稀释,或者切分点把关键信息切断了);最后看有没有做归一化和相似度阈值过滤——如果你没设阈值,即便最相关的 chunk 相似度只有 0.3(很低),系统也会硬塞给用户,看起来就像是”随便返回了点什么”。建议先挑 10 个典型查询人工核对召回的 chunk 是否合理,这比看似”接口一切正常”的日志有用得多。
批量 embed 大量历史文档时,进度中断了怎么办? 给每条文本生成一个基于内容的哈希 ID,embedding 结果按 ID 存储或标记完成状态,中断后重新跑时先检查哪些 ID 已经处理过,跳过已完成的,不要从头再来——尤其是几万条规模的任务,重跑一次的时间和费用成本都不小。
更多接入基础见大模型 API 接入完全指南与接入教程专题。Embedding 之后的重排序优化见 Rerank 重排接口接入;完整 RAG 应用搭建参考RAG 应用搭建指南。