Embedding 模型 10+ 横向评测

20 阅读4分钟

03 · Embedding 模型 10+ 横向评测

Chunking 把文本切好了,下一步是把它变成向量。不同 Embedding 模型在中文场景的差距可以大到 30%+。本篇用统一测试集,Node.js 实测 10+ 模型。


1. 评测框架设计

1.1 评测维度

维度指标说明
检索精度MRR / NDCG@5排序是否正确
语义区分正负样本分离度相似和不同的文本距离差
中文能力C-MTEB 代理测试分类、聚类、检索、重排
推理速度tokens/s纯推理时间,不含网络延迟
部署成本$/1M tokensAPI 价格或 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 成本
1text-embedding-3-small512/1536OpenAI API$0.02
2text-embedding-3-large256/1024/3072OpenAI API$0.13
3text-embedding-ada-0021536OpenAI API$0.10
4bge-large-zh-v1.51024本地/RunPodGPU 成本
5bge-m31024本地/RunPodGPU 成本
6m3e-large1024本地/RunPodGPU 成本
7m3e-base768本地GPU 成本
8jina-embeddings-v31024API1M 免费/日
9multilingual-e5-large1024本地GPU 成本
10Cohere Embed v31024API$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@5MRR分离度中文检索能力
🥇bge-large-zh-v1.50.8710.9120.853⭐⭐⭐⭐⭐
🥈text-embedding-3-large (3072d)0.8480.8870.821⭐⭐⭐⭐
🥉m3e-large0.8390.8810.815⭐⭐⭐⭐⭐
4bge-m30.8310.8730.808⭐⭐⭐⭐
5text-embedding-3-large (1024d)0.8180.8620.790⭐⭐⭐⭐
6text-embedding-3-small (1536d)0.7920.8410.765⭐⭐⭐
7multilingual-e5-large0.7850.8330.758⭐⭐⭐
8Cohere Embed v30.7810.8280.752⭐⭐⭐
9jina-embeddings-v30.7760.8200.747⭐⭐⭐
10m3e-base0.7530.8050.725⭐⭐⭐
11text-embedding-3-small (512d)0.7290.7820.698⭐⭐
12text-embedding-ada-0020.7010.7560.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. 实战建议

  1. 中文场景闭眼选 bge-large-zh-v1.5,这是当前中文 Embedding 的 SOTA(开源范围内)
  2. 不要迷信高维度,1024d 在检索场景是最优性价比
  3. 本地部署值得投入——一台带 GPU 的机器跑 Embedding,省 90% 的 API 费用,延迟低 10x
  4. 评测要在你自己的数据上做——每个领域的文本分布不同,通用评测只能参考不能迷信
  5. 批量调用——单条调用 API 的延迟 = 网络 RTT + 推理时间,批量后网络开销被均摊

上一篇:02 · 文档摄入与 Chunking 策略全对决 下一篇:04 · 向量数据库选型与生产级实战