Skip to content

Embedding 模型选型与批处理:别用对话模型做向量化 ​

1. 本节产出 ​

一个可切换的 Embedding 封装:国内主流 Embedding 模型可配置切换、支持批处理与失败重试、维度与向量库一致,并且有一份自己实测的选型对比表。

2. 前置依赖 ​

3. 为什么 Embedding 不能用对话模型将就 ​

有个项目为了省事,用对话模型的隐藏层输出当向量。三个后果:

问题表现
语义相似度不靠谱「如何请假」和「请假流程」的相似度低于「如何请假」和「如何报销」
维度过大4096 维,存储和检索都慢
成本极高对话模型调用费远高于专用 Embedding 模型

根因:对话模型是为「生成下一个 Token」训练的,它的表示空间关注的是「接下来该说什么」,而不是「这两句话意思是否相近」。专用 Embedding 模型是用对比学习训练的,目标就是让语义相近的文本在向量空间里靠近。

结论:Embedding 一定要用专用模型。 这不是优化建议,是必要条件。

4. 核心原理 ​

4.1 选型要看的四件事 ​

维度关注点影响
中文能力是否在中文语料上训练过决定相似度是否靠谱(最关键)
维度768 / 1024 / 1536 / 2048存储成本、检索速度、精度
最大输入长度通常 512~8192 Token超过会被截断,长片段要再切
单价与吞吐元/百万 Token、单次批量上限灌库成本与时间

维度不是越高越好。1536 维到 3072 维的精度提升通常很小,但存储翻倍、检索变慢。项目初期选 768~1536 即可。

4.2 国内主流选择对比(以实测为准) ​

模型维度中文能力特点
通义 text-embedding-v31024强支持自定义维度,性价比高
BGE-M3(开源可私有化)1024强支持多语言与稀疏向量,可本地部署
BCE / bge-large-zh768~1024强中文语义检索表现好
OpenAI text-embedding-31536中中文表现一般,需实测

提示:这张表只能作为起点。Embedding 选型必须用你自己的文档和评测集实测,因为不同领域的语义分布差异很大。通用榜单第一的模型在你的业务上未必最好。

4.3 批处理:灌库时间的分水岭 ​

单条调用:10000 片段 × 0.3s = 50 分钟
批量 32:   10000 片段 ÷ 32 × 0.6s ≈ 3 分钟

批处理能把灌库时间压缩一个数量级,因为网络往返是主要开销。但要注意:

注意点说明
批量上限各厂商不同(常见 16/25/32/64),超了报错
失败重试整批失败要能拆分重试,不能整批丢弃
限流批量不减少 Token 消耗,仍受配额限制
长文本超过模型最大长度会被截断,先切再批

4.4 一个容易忽略的对称性要求 ​

查询向量和文档向量必须用同一个模型、同一个版本生成。

正确:文档用 v3 向量化 → 查询也用 v3 向量化 → 可比
错误:文档用 v3 向量化 → 查询用 v2 向量化 → 完全不可比,检索全乱

模型升级(v2 → v3)意味着全库重灌。这是一个真实的运维成本,做选型时要考虑进去。

5. 代码走查 ​

5.1 配置 ​

yaml
# application.yml
ai:
  embedding:
    provider: qwen            # 可切换
    model: text-embedding-v3
    dimensions: 1024
    batch-size: 32
    max-input-tokens: 8000

提示:dimensions 必须和向量库建表时的维度一致。不一致时通常在第一次查询时才报「维度不匹配」,而不是在启动时——所以灌库前先确认一次。

5.2 统一封装 ​

java
// src/main/java/com/example/rag/config/EmbeddingConfig.java
@Configuration
public class EmbeddingConfig {

    @Bean
    public EmbeddingModel embeddingModel(EmbeddingProperties props) {
        OpenAiApi api = OpenAiApi.builder()
                .apiKey(props.apiKey())
                .baseUrl(props.baseUrl())      // 国内厂商的兼容端点
                .build();

        return new OpenAiEmbeddingModel(
                OpenAiEmbeddingOptions.builder()
                        .model(props.model())
                        .dimensions(props.dimensions())   // 部分厂商支持自定义维度
                        .build(),
                api);
    }
}

5.3 批处理与失败重试 ​

java
// src/main/java/com/example/rag/ingest/BatchEmbedder.java
@Service
public class BatchEmbedder {

    private final EmbeddingModel model;
    private final int batchSize;

    /** 批量向量化;单批失败时拆半重试,避免整批丢失 */
    public List<float[]> embedAll(List<String> texts) {
        List<float[]> result = new ArrayList<>(texts.size());
        for (int i = 0; i < texts.size(); i += batchSize) {
            List<String> batch = texts.subList(i, Math.min(i + batchSize, texts.size()));
            result.addAll(embedWithRetry(batch));
            // 限速:避免触发厂商配额
            if (i + batchSize < texts.size()) sleepQuietly(120);
        }
        return result;
    }

    private List<float[]> embedWithRetry(List<String> batch) {
        try {
            return model.embed(batch).stream()
                    .map(float[]::clone)
                    .toList();
        } catch (Exception first) {
            log.warn("批量向量化失败 size={},拆半重试", batch.size(), first);
            if (batch.size() == 1) throw first;      // 单条都失败,抛出去

            int mid = batch.size() / 2;
            List<float[]> left = embedWithRetry(batch.subList(0, mid));
            List<float[]> right = embedWithRetry(batch.subList(mid, batch.size()));
            return Stream.concat(left.stream(), right.stream()).toList();
        }
    }
}

拆半重试这个设计很实用:批量调用时只要有一条文本有问题(比如含特殊字符、超长),整批都会失败。拆半重试能把问题文本隔离出来,而不是丢掉整批。

5.4 超长文本保护 ​

java
private String truncate(String text, int maxTokens) {
    int tokens = TokenEstimator.estimate(text);
    if (tokens <= maxTokens) return text;
    // 截断而不是让厂商静默截断——静默截断会导致向量代表不了全文
    log.warn("文本超长,已截断:{} tokens → {}", tokens, maxTokens);
    return text.substring(0, (int) (text.length() * (maxTokens / (double) tokens)));
}

6. 跑起来 ​

bash
git checkout ch02-05-embedding
mvn -q test -Dtest=EmbeddingSelectionTest

选型实测(本节核心产出):用一组已知相似度的中文句子对,跑不同模型看相似度排序。

bash
# 输出示例
模型:qwen-v3  维度 1024
  「如何申请年假」 vs 「年假申请流程是什么」   → 0.91  ✅ 应为高
  「如何申请年假」 vs 「差旅报销标准是多少」   → 0.23  ✅ 应为低
  「服务器宕机了」 vs 「服务不可用」           → 0.87  ✅ 应为高
  「服务器宕机了」 vs 「服务器采购流程」       → 0.31  ✅ 应为低

模型:openai-3-small  维度 1536
  「如何申请年假」 vs 「年假申请流程是什么」   → 0.78  ⚠️ 偏低
  ...
检查项通过标准
相似对得分> 0.8
不相似对得分< 0.4
区分度相似对与不相似对分差 > 0.4
批处理1000 条片段在 1 分钟内完成
失败重试注入一条异常文本,验证拆半重试能隔离它

这张实测表要保存下来,它是你交付给客户的选型依据,比任何公开榜单都有说服力。

7. 生产避坑 ​

  1. 查询和文档必须用同一个 Embedding 模型与版本。这是最容易在模型升级时翻车的地方:只升级了查询侧,忘记重灌文档库,结果检索质量断崖式下跌且不报错。做法:把模型名和版本写进每个片段的元数据,查询时校验一致性。
  2. 批量调用失败要有拆半重试。整批丢弃会导致灌库静默丢数据——文档看起来灌进去了,实际少了几十个片段,排查极其困难。
  3. 不要相信「维度越高越好」。实测中 1024 维和 3072 维在多数业务上差距很小,但存储和检索开销差 3 倍。先用中等维度跑评测集,确认有收益再考虑升维。

8. 延伸与锚点 ​

  • 思考题:文档库有 200 万片段,现在要换 Embedding 模型,全量重灌要多久、多少钱?有没有不停机的办法?(提示:双写 + 灰度切换——答案在 02-14 增量更新)
  • 代码锚点:git checkout ch02-05-embedding
  • 下一课时:02-06 向量库选型
  • 对应课件:L02-05 Embedding 模型选型