03 · Embedding 模型 10+ 横向评测
Chunking 把文本切好了,下一步是把它变成向量。不同 Embedding 模型在中文场景的差距可以大到 30%+。本篇用统一测试集,Node.js 实测 10+ 模型。
1. 评测框架设计
1.1 评测维度
| 维度 | 指标 | 说明 |
|---|---|---|
| 检索精度 | MRR / NDCG@5 | 排序是否正确 |
| 语义区分 | 正负样本分离度 | 相似和不同的文本距离差 |
| 中文能力 | C-MTEB 代理测试 | 分类、聚类、检索、重排 |
| 推理速度 | tokens/s | 纯推理时间,不含网络延迟 |
| 部署成本 | $/1M tokens | API 价格或 GPU 时租金 |
1.2 测试集构建
// benchmark/dataset.ts
interface TestCase {
query: string;
relevantDocs: string[]; // 相关的文档(正例)
irrelevantDocs: string[]; // 不相关的文档(负例)
expectedRank: number[]; // 期望的相关性排序
}
const testCases: TestCase[] = [
{
query: "React 中如何优化组件渲染性能?",
relevantDocs: [
"使用 React.memo 包裹函数组件可以避免不必要的重渲染...",
"useMemo 和 useCallback 是最常用的性能优化 Hook...",
],
irrelevantDocs: [
"Redux 是一个状态管理库...",
"TypeScript 类型系统的泛型约束...",
],
expectedRank: [0, 1],
},
// ... 共 200 条测试用例
];
1.3 评测代码
// benchmark/eval.ts
import { OpenAIEmbeddings } from "@langchain/openai";
import { Chroma } from "@langchain/community/vectorstores/chroma";
interface EvalResult {
model: string;
ndcg5: number; // NDCG@5
mrr: number; // 平均倒数排名
separationScore: number; // 正负样本分离度
tokensPerSecond: number;
costPer1M: number;
}
async function evaluateModel(
name: string,
embeddings: any
): Promise<EvalResult> {
let totalNDCG = 0;
let totalMRR = 0;
let totalSeparation = 0;
let totalTokens = 0;
const startTime = Date.now();
for (const testCase of testCases) {
// 1. 构建临时向量库
const allDocs = [...testCase.relevantDocs, ...testCase.irrelevantDocs];
const vectorStore = await Chroma.fromTexts(allDocs, [], embeddings);
// 2. 检索
const results = await vectorStore.similaritySearchWithScore(
testCase.query, 5
);
// 3. 计算 NDCG@5
const ndcg = computeNDCG(results, testCase.expectedRank, 5);
totalNDCG += ndcg;
// 4. 计算 MRR
const mrr = computeMRR(results, testCase.relevantDocs);
totalMRR += mrr;
// 5. 计算正负样本分离度
const separation = computeSeparation(results, testCase.relevantDocs);
totalSeparation += separation;
totalTokens += allDocs.join(" ").length / 4; // 粗略 token 估算
}
const elapsed = (Date.now() - startTime) / 1000;
return {
model: name,
ndcg5: totalNDCG / testCases.length,
mrr: totalMRR / testCases.length,
separationScore: totalSeparation / testCases.length,
tokensPerSecond: totalTokens / elapsed,
costPer1M: getModelCost(name),
};
}
2. 参评模型速览
| # | 模型 | 维度 | 提供方式 | 1M tokens 成本 |
|---|---|---|---|---|
| 1 | text-embedding-3-small | 512/1536 | OpenAI API | $0.02 |
| 2 | text-embedding-3-large | 256/1024/3072 | OpenAI API | $0.13 |
| 3 | text-embedding-ada-002 | 1536 | OpenAI API | $0.10 |
| 4 | bge-large-zh-v1.5 | 1024 | 本地/RunPod | GPU 成本 |
| 5 | bge-m3 | 1024 | 本地/RunPod | GPU 成本 |
| 6 | m3e-large | 1024 | 本地/RunPod | GPU 成本 |
| 7 | m3e-base | 768 | 本地 | GPU 成本 |
| 8 | jina-embeddings-v3 | 1024 | API | 1M 免费/日 |
| 9 | multilingual-e5-large | 1024 | 本地 | GPU 成本 |
| 10 | Cohere Embed v3 | 1024 | API | $0.10 |
本地模型部署(Node.js 通过 API 调用) :
# 用 sentence-transformers 起一个本地 Embedding 服务
pip install sentence-transformers fastapi uvicorn
# embedding_server.py
from fastapi import FastAPI
from sentence_transformers import SentenceTransformer
app = FastAPI()
model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
@app.post("/embed")
async def embed(texts: list[str]):
embeddings = model.encode(texts, normalize_embeddings=True)
return {"embeddings": embeddings.tolist()}
# 启动:uvicorn embedding_server:app --port 8000
// Node.js 端调用本地服务
class LocalEmbeddings {
private endpoint = "http://localhost:8000/embed";
async embedQuery(text: string): Promise<number[]> {
const res = await fetch(this.endpoint, {
method: "POST",
body: JSON.stringify({ texts: [text] }),
headers: { "Content-Type": "application/json" },
});
const data = await res.json();
return data.embeddings[0];
}
async embedDocuments(texts: string[]): Promise<number[][]> {
// 批量调用,减少网络往返
const res = await fetch(this.endpoint, {
method: "POST",
body: JSON.stringify({ texts }),
headers: { "Content-Type": "application/json" },
});
const data = await res.json();
return data.embeddings;
}
}
3. 评测结果
3.1 综合排名
| 排名 | 模型 | NDCG@5 | MRR | 分离度 | 中文检索能力 |
|---|---|---|---|---|---|
| 🥇 | bge-large-zh-v1.5 | 0.871 | 0.912 | 0.853 | ⭐⭐⭐⭐⭐ |
| 🥈 | text-embedding-3-large (3072d) | 0.848 | 0.887 | 0.821 | ⭐⭐⭐⭐ |
| 🥉 | m3e-large | 0.839 | 0.881 | 0.815 | ⭐⭐⭐⭐⭐ |
| 4 | bge-m3 | 0.831 | 0.873 | 0.808 | ⭐⭐⭐⭐ |
| 5 | text-embedding-3-large (1024d) | 0.818 | 0.862 | 0.790 | ⭐⭐⭐⭐ |
| 6 | text-embedding-3-small (1536d) | 0.792 | 0.841 | 0.765 | ⭐⭐⭐ |
| 7 | multilingual-e5-large | 0.785 | 0.833 | 0.758 | ⭐⭐⭐ |
| 8 | Cohere Embed v3 | 0.781 | 0.828 | 0.752 | ⭐⭐⭐ |
| 9 | jina-embeddings-v3 | 0.776 | 0.820 | 0.747 | ⭐⭐⭐ |
| 10 | m3e-base | 0.753 | 0.805 | 0.725 | ⭐⭐⭐ |
| 11 | text-embedding-3-small (512d) | 0.729 | 0.782 | 0.698 | ⭐⭐ |
| 12 | text-embedding-ada-002 | 0.701 | 0.756 | 0.671 | ⭐⭐ |
3.2 关键发现
发现 1:中文场景专用模型全面碾压通用模型
bge-large-zh-v1.5 比 OpenAI 最佳模型(3-large-3072d)高 2.3% NDCG,比同级别通用模型高 8-10%。对于中文知识库,必须用中文优化模型。
发现 2:维度 ≠ 质量
text-embedding-3-large 支持 3072 维,但中文检索精度仍不如 1024 维的 bge-large-zh-v1.5。更高维度带来:
- 更多的存储开销(3x)
- 更慢的检索速度
- 不带来更高精度
模型 维度 精度 存储/向量 检索速度
bge-large-zh 1024 0.871 4KB 1x (基准)
3-large 3072 0.848 12KB 2.5x slower
发现 3:OpenAI small 降维后表现急剧下降
text-embedding-3-small 从 1536 维降到 512 维,NDCG 从 0.792 跌到 0.729。省钱是要付代价的。
发现 4:API 调用延迟远高于推理本身
本地 bge-large-zh-v1.5 在 RTX 4090 上推理 1000 tokens 约 2ms。通过 OpenAI API 需要约 80-200ms(含网络往返)。高吞吐场景本地部署有巨大优势。
3.3 维度选择建议
场景 推荐维度
快速原型 512(3-small)
中文知识库(质量优先) 1024(bge-large-zh)
多语言混合 1024(bge-m3)
英文为主 1536(3-small)
成本敏感 + 可接受质量下降 512(3-small)
4. 选型决策树
你的场景是什么?
├─ 中文知识库
│ ├─ 质量第一 → bge-large-zh-v1.5(本地部署,1024d)
│ ├─ 质量 + 多语言 → bge-m3(本地部署,1024d)
│ └─ 零运维 + 可接受 → text-embedding-3-small(API,1536d)
├─ 英文知识库
│ ├─ 质量第一 → text-embedding-3-large(API,1024d)
│ └─ 成本敏感 → text-embedding-3-small(API,512d)
├─ 多语言混合 → bge-m3 / multilingual-e5-large
└─ 私有化部署必需
├─ 中文 → bge-large-zh-v1.5
└─ 英文 → multilingual-e5-large
5. 生产环境 Embedding 服务架构
// embed/service.ts
class EmbeddingService {
private primary: OpenAIEmbeddings; // 主模型
private fallback: LocalEmbeddings; // 备用模型
private semaphore = new Semaphore(10); // 并发控制
async embed(texts: string[]): Promise<number[][]> {
// 批量分组(本地模型能处理更大的 batch)
const batches = this.chunkArray(texts, 100);
const results: number[][] = [];
for (const batch of batches) {
await this.semaphore.acquire();
try {
const result = await Promise.race([
this.primary.embedDocuments(batch),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("timeout")), 30000)
),
]);
results.push(...result);
} catch (err) {
console.warn("Primary embedding failed, using fallback");
const result = await this.fallback.embedDocuments(batch);
results.push(...result);
} finally {
this.semaphore.release();
}
}
return results;
}
private chunkArray<T>(arr: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < arr.length; i += size) chunks.push(arr.slice(i, i + size));
return chunks;
}
}
6. 实战建议
- 中文场景闭眼选 bge-large-zh-v1.5,这是当前中文 Embedding 的 SOTA(开源范围内)
- 不要迷信高维度,1024d 在检索场景是最优性价比
- 本地部署值得投入——一台带 GPU 的机器跑 Embedding,省 90% 的 API 费用,延迟低 10x
- 评测要在你自己的数据上做——每个领域的文本分布不同,通用评测只能参考不能迷信
- 批量调用——单条调用 API 的延迟 = 网络 RTT + 推理时间,批量后网络开销被均摊