14.06-Embedding向量化

要点

  • Embedding 把文本转成高维向量——语义相近的文本,向量距离也近
  • Embedding 模型的选择决定了向量空间的语义——不同模型擅长的领域不同
  • 主流方案:OpenAI text-embedding、Cohere embed、开源模型(BGE、E5)、Workers AI
  • 批处理很重要——单条调用和批量调用的成本差异巨大
  • 中英文场景的模型选择差异大——中文专用模型效果更好
  • Embedding 和检索模型必须匹配——同一个模型编码的向量和查询才能比较

1. Embedding 是什么

Embedding 模型把一段文本映射到一个高维向量空间。可以把它想象成:每段文本在这个空间里有一个坐标,意思相近的文本坐标离得近。

"今天天气真好"   → [0.12, -0.34, 0.88, ..., 0.05]   (768 维)
"阳光很棒"       → [0.11, -0.32, 0.85, ..., 0.07]   (和上面距离很近)
"今天股票跌了"   → [-0.45, 0.67, -0.12, ..., 0.33]  (和上面距离很远)

向量之间的距离可以用余弦相似度、欧氏距离、点积来衡量。文本检索场景几乎都用余弦相似度。

Embedding 模型的关键特性:

  • 维度:向量的长度(768、1024、1536 等)。维度越高,理论上表达力越强,但存储和计算成本也越高
  • 最大输入长度:模型能处理的最大 token 数(通常 512 或 8192)
  • 训练语料:决定了模型擅长什么领域

2. 主流 Embedding 模型

OpenAI text-embedding

// OpenAI 的 Embedding 模型
import OpenAI from 'openai'
 
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
 
async function embedWithOpenAI(texts: string[]): Promise<number[][]> {
  const response = await openai.embeddings.create({
    model: 'text-embedding-3-small',  // 或 text-embedding-3-large
    input: texts,
  })
  return response.data.map((d) => d.embedding)
}
模型维度价格(每百万 token)特点
text-embedding-3-small1536(可降维)$0.02便宜,通用
text-embedding-3-large3072(可降维)$0.13精度高
text-embedding-ada-0021536$0.10旧版,逐渐淘汰

text-embedding-3 支持降维——可以指定输出 256-3072 之间的任意维度,在精度和成本之间取舍。

Cohere embed

import { CohereClient } from 'cohere-ai'
 
const cohere = new CohereClient({ token: process.env.COHERE_API_KEY })
 
async function embedWithCohere(texts: string[]): Promise<number[][]> {
  const response = await cohere.embed({
    texts,
    model: 'embed-multilingual-v3.0',  // 多语言支持好
    inputType: 'search_document',      // 文档 vs 查询有不同模式
  })
  return response.embeddings
}

Cohere 的特色:

  • 多语言支持好——embed-multilingual 对中文、日文等非英文语言效果好
  • input_type 区分search_document(索引时用)和 search_query(查询时用)——同一个模型对文档和查询用不同的编码方式,提升检索效果
  • 压缩表示:支持 compressed 输出,减少存储

开源模型

如果不想依赖第三方 API,可以自托管开源模型。

// 用 @xenova/transformers 在 Node.js 中运行开源模型
import { pipeline } from '@xenova/transformers'
 
const embedder = await pipeline('feature-extraction', 'BAAI/bge-base-zh-v1.5')
 
async function embedLocally(texts: string[]): Promise<number[][]> {
  const embeddings = []
  for (const text of texts) {
    const output = await embedder(text, { pooling: 'mean', normalize: true })
    embeddings.push(Array.from(output.data))
  }
  return embeddings
}

主流开源 Embedding 模型:

模型维度特点
BAAI/bge-base-zh-v1.5768中文效果好
BAAI/bge-m31024多语言,支持稀疏 + 稠密
intfloat/multilingual-e5-large1024多语言通用
sentence-transformers/all-MiniLM-L6-v2384轻量快速,英文为主

Workers AI(Cloudflare)

// Cloudflare Workers AI 内置的 Embedding 模型
async function embedWithWorkersAI(
  texts: string[],
  ai: Ai  // Workers AI binding
): Promise<number[][]> {
  const response = await ai.run('@cf/baai/bge-base-en-v1.5', {
    text: texts,  // 一次最多 100 条
  })
  return response.data
}

Workers AI 的好处是和 Vectorize 集成方便,坏处是模型选择有限。

3. 模型选型决策

选型的关键问题:

问题一:内容是中文还是多语言?

  • 纯英文 → text-embedding-3-small 或 all-MiniLM-L6-v2 够用
  • 中文为主 → bge-base-zh-v1.5 或 bge-m3
  • 多语言混合 → embed-multilingual-v3.0 或 bge-m3

问题二:对成本敏感吗?

  • 成本敏感 → text-embedding-3-small($0.02/1M token)或自托管开源模型
  • 精度优先 → text-embedding-3-large 或 bge-m3

问题三:能接受第三方依赖吗?

  • 能接受 → OpenAI、Cohere 的 API
  • 不接受 → 自托管开源模型

问题四:数据量多大?

  • < 100 万 chunk → 任何方案都行
  • 100 万 - 1 亿 → 考虑批处理成本和存储成本
  • > 1 亿 → 自托管 + 量化压缩

4. 批处理

Embedding API 通常支持批处理。批量调用比逐条调用便宜很多。

// ❌ 逐条调用——贵 10-100 倍
for (const chunk of chunks) {
  const embedding = await embedAPI(chunk.text)  // 每次一个 API 调用
  await vectorDB.upsert({ id: chunk.id, vector: embedding })
}
 
// ✅ 批量调用
const BATCH_SIZE = 100
for (let i = 0; i < chunks.length; i += BATCH_SIZE) {
  const batch = chunks.slice(i, i + BATCH_SIZE)
  const embeddings = await embedAPI(batch.map((c) => c.text))
 
  const vectors = batch.map((chunk, j) => ({
    id: chunk.id,
    vector: embeddings[j],
    metadata: chunk.metadata,
  }))
 
  await vectorDB.upsert(vectors)
}

批处理注意事项:

  • 每批不要太大——API 有请求大小限制(OpenAI 最多 2048 条,Workers AI 最多 100 条)
  • 注意限流——批量调用可能触发 rate limit,加重试逻辑
  • 错误隔离——一批里某条失败,不应该全部重做

5. 文档和查询用不同的编码

一些模型(如 Cohere embed、E5)对文档和查询用不同的前缀或模式。

// Cohere 的 input_type
const docEmbedding = await cohere.embed({
  texts: ['这是一段文档内容...'],
  model: 'embed-multilingual-v3.0',
  inputType: 'search_document',  // 索引文档时用
})
 
const queryEmbedding = await cohere.embed({
  texts: ['用户的问题'],
  model: 'embed-multilingual-v3.0',
  inputType: 'search_query',  // 查询时用
})

为什么?文档通常更长、更完整,查询更短、更聚焦。用不同的编码方式可以让查询向量和文档向量在同一个空间里更好地对齐。

E5 模型用前缀区分:

// E5 模型
const docText = `passage: ${documentText}`
const queryText = `query: ${queryText}`

如果索引和查询用同一个模型但没有正确设置 input_type 或前缀,检索效果会明显变差。

6. 统一 Embedding 服务

把 Embedding 调用封装成统一接口,屏蔽底层模型差异:

// src/services/embedding/index.ts
 
export type EmbeddingProvider = {
  name: string
  dimensions: number
  embed(texts: string[]): Promise<number[][]>
}
 
// OpenAI provider
class OpenAIEmbeddingProvider implements EmbeddingProvider {
  name = 'openai'
  dimensions = 1536
 
  async embed(texts: string[]): Promise<number[][]> {
    const response = await openai.embeddings.create({
      model: 'text-embedding-3-small',
      input: texts,
    })
    return response.data.map((d) => d.embedding)
  }
}
 
// 本地 provider
class LocalEmbeddingProvider implements EmbeddingProvider {
  name = 'local'
  dimensions = 768
  private embedder: any
 
  async init() {
    this.embedder = await pipeline('feature-extraction', 'BAAI/bge-base-zh-v1.5')
  }
 
  async embed(texts: string[]): Promise<number[][]> {
    const results = []
    for (const text of texts) {
      const output = await this.embedder(text, { pooling: 'mean', normalize: true })
      results.push(Array.from(output.data))
    }
    return results
  }
}
 
// 统一接口
export class EmbeddingService {
  private provider: EmbeddingProvider
 
  constructor(provider: EmbeddingProvider) {
    this.provider = provider
  }
 
  get dimensions() {
    return this.provider.dimensions
  }
 
  async embed(texts: string[]): Promise<number[][]> {
    // 批处理 + 重试
    const BATCH_SIZE = 100
    const allEmbeddings: number[][] = []
 
    for (let i = 0; i < texts.length; i += BATCH_SIZE) {
      const batch = texts.slice(i, i + BATCH_SIZE)
      let attempts = 0
      while (attempts < 3) {
        try {
          const embeddings = await this.provider.embed(batch)
          allEmbeddings.push(...embeddings)
          break
        } catch (err) {
          attempts++
          if (attempts >= 3) throw err
          await new Promise((r) => setTimeout(r, 1000 * attempts))
        }
      }
    }
 
    return allEmbeddings
  }
}

7. 缓存策略

同样的文本不需要重复调用 Embedding API。

// 用 Redis 或数据库缓存 embedding
async function embedWithCache(texts: string[]): Promise<number[][]> {
  const results: (number[] | null)[] = []
  const uncached: Array<{ text: string; index: number }> = []
 
  // 检查缓存
  for (let i = 0; i < texts.length; i++) {
    const cacheKey = `embed:${hash(texts[i])}`
    const cached = await redis.get(cacheKey)
    if (cached) {
      results[i] = JSON.parse(cached)
    } else {
      results[i] = null
      uncached.push({ text: texts[i], index: i })
    }
  }
 
  // 批量调用未缓存的
  if (uncached.length > 0) {
    const embeddings = await embeddingService.embed(uncached.map((u) => u.text))
 
    for (let i = 0; i < uncached.length; i++) {
      const { text, index } = uncached[i]
      results[index] = embeddings[i]
 
      // 写入缓存(设置过期时间)
      const cacheKey = `embed:${hash(text)}`
      await redis.set(cacheKey, JSON.stringify(embeddings[i]), 'EX', 86400 * 7)  // 7 天
    }
  }
 
  return results as number[][]
}

缓存的好处:

  • 重复文档不重复计算——更新文档时只计算新增的 chunk
  • 查询缓存——同一个问题短时间内多次查询,复用 embedding

8. 成本控制

Embedding 调用是 RAG 的持续成本之一。

// 成本估算
const dailyQueries = 1000
const avgChunkCount = 5  // 每次查询检索 5 个 chunk
 
// 索引成本(一次性)
const totalChunks = 100000
const avgChunkTokens = 200
const indexTokens = totalChunks * avgChunkTokens
const indexCost = (indexTokens / 1_000_000) * 0.02  // text-embedding-3-small: $0.02/1M
 
// 查询成本(持续)
const queryTokensPerDay = dailyQueries * 50  // 每次查询约 50 token
const queryCostPerDay = (queryTokensPerDay / 1_000_000) * 0.02
 
// 每月总成本
const monthlyCost = indexCost + queryCostPerDay * 30
// ≈ $0.04 + $0.03 = $0.07/月(非常低)

对于中小规模应用,Embedding 成本几乎可以忽略。真正的成本大头在 LLM 生成(第 13 章的内容)。

总结

回顾这一节的要点:

  • Embedding 把文本转成向量,语义相近的文本向量距离也近
  • 模型选型看:语言、成本、第三方依赖、数据量
  • 中文场景用 bge-base-zh 或 bge-m3,英文用 text-embedding-3-small
  • 批处理比逐条调用便宜很多
  • 文档和查询可能需要不同的编码模式(input_type 或前缀)
  • 统一 Embedding 服务屏蔽底层差异
  • 缓存可以避免重复计算
  • Embedding 成本通常很低,LLM 生成才是大头

下一篇讲向量数据库选型——有了向量,存在哪里?