✨ 我是 Muzi 的「文章捕手」,擅长在文字的星海中打捞精华。每当新的篇章诞生,我就会像整理贝壳一样,将思想的闪光点串成珍珠项链~

文章围绕Spring AI中的BatchingStrategy扩展点,解决了向量存储调用DashScope嵌入接口时文本批次大小限制的问题。作者指出默认的TokenCountBatchingStrategy仅按token数切分,忽略了厂商对单次请求文档条数的限制,容易触发服务端拒绝。通过实现DashScopeBatchingStrategy,结合文档条数和token双重分批,解决了批次超限问题,同时保证顺序一致性,避免向量错配。文中详细介绍了策略在调用链中的位置、覆盖方式的静默特性及对应的测试用例,强调了扩展点配置的可验证性和横切关注点集中管理的重要性。实测确认策略改造无行为差异但增强了系统健壮性和维护性。

# Mu-ai-agent-9

# 前言

前两篇把 RAG 链路搭了起来:TikaDocumentReader 读文档、TokenTextSplitter 切块、text-embedding-v3 向量化,再换到 PgVectorStore 做持久化。跑通之后,灌库环节留了个隐患。

知识库里的 7 篇 Markdown 最终切出 12 个分块,而 DashScope 的 embedding 接口对「单次请求的文本条数」是有硬性限制的。当时的写法是在 DocumentLoader 里手动按 10 条切片、循环提交:

// 改造前:在业务代码里手动绕开厂商限制
for (int i = 0; i < allChunks.size(); i += 10) {
    int end = Math.min(i + 10, allChunks.size());
    vectorStore.add(allChunks.subList(i, end));
}

能跑,但把「DashScope 单次最多 10 条」这个基础设施细节硬塞进了业务代码。以后厂商改限制要改这里,而且别的调用方(检索、换用其他 VectorStore)还得各自再写一遍同样的循环。

后来翻 Spring AI 的源码才发现,官方早就为这件事留好了扩展点—BatchingStrategy。这一篇把它从头讲清楚,中间还踩了个坑:这个扩展点的覆盖方式是静默的,改错了不会报错。


# 批处理策略在哪儿被调用

先搞清楚它在调用链上的位置,不然容易把它当成一个孤立的工具类。

VectorStore.add(List<Document>) 会走到 EmbeddingModel.embed(...),而 embedding 这一层才决定「一次 HTTP 请求发几条文本」。Spring AI 在 EmbeddingModel 的默认实现里把切批这件事抽了出来:

// EmbeddingModel 接口的 default 实现(简化后)
default void embed(List<Document> documents, EmbeddingOptions options, BatchingStrategy batchingStrategy) {
    for (List<Document> batch : batchingStrategy.batch(documents)) {
        call(new EmbeddingRequest(batch, options));
    }
}

看得很清楚:策略切出几批,就会发起几次 HTTP 请求。所以「一次请求塞多少文档」这件事,完全由 BatchingStrategy 说了算。

这里还有个容易让人怀疑「策略到底生效没有」的细节:DashScope 的 DashScopeEmbeddingModel 覆写了这个三参 embed(...),但它只是把默认的 options 补齐,然后转手调回上面的默认实现。也就是说策略是真的在起作用,不是被厂商实现绕过了。


# 默认策略的问题:只按 token 切,不管条数

不写任何配置时,PgVectorStoreAutoConfiguration 会装配它自带的默认策略:

@Bean
@ConditionalOnMissingBean
BatchingStrategy pgVectorStoreBatchingStrategy() {
    return new TokenCountBatchingStrategy();   // CL100K_BASE + 8191 tokens + 保留 10%
}

TokenCountBatchingStrategy 干的事是按 token 预算切批:估算每篇文档的 token 数,攒到接近预算(默认 8191,再留出 10% 余量,实际可用约 7372)就切一刀。

问题在于:它对「单批的文档条数」没有任何上限。10 段很短的文本可能一共才几百个 token,远没碰到预算,但条数上早就越过了 DashScope 的线,服务端会直接拒绝。

条数和 token 是两把完全不同的尺子:

  • 10 段短文本 → token 没超,条数超了
  • 2 段超长文本 → 条数没超,token 可能已经爆了

所以两个上限必须同时生效,默认策略只守住了其中一个。


# 覆盖默认策略:注意它是静默的

覆盖方式很简单,自己声明一个 BatchingStrategy Bean 就行——上面那个默认策略带 @ConditionalOnMissingBean,会自动退让。

@Bean
public BatchingStrategy batchingStrategy(...) {
    return new DashScopeBatchingStrategy(...);
}

但这里有个必须知道的特性:@ConditionalOnMissingBean 的特点是「被覆盖」和「没被覆盖」两种情况下程序都能正常启动。如果哪天这个配置类被挪走、扫描不到,或者引入的其他 Starter 也声明了 BatchingStrategy,结果是静默退回默认策略——请求照样发出去,只是可能因为单次条数超限被服务端拒绝,排查起来毫无头绪。

这类「失败方式是静默的」扩展点,光写实现不够,测试里必须断言「当前注入的确实是我这个实现」。后面测试那一节会专门说。


# 实现 DashScopeBatchingStrategy

思路是两级切分:先按条数分组,再交给现成的 token 策略细分。

@Override
public List<List<Document>> batch(List<Document> documents) {
    if (documents == null || documents.isEmpty()) {
        return Collections.emptyList();
    }

    int total = documents.size();
    List<List<Document>> batches = new ArrayList<>();

    // 第一级:按条数上限分组
    for (int start = 0; start < total; start += maxDocumentsPerRequest) {
        int end = Math.min(start + maxDocumentsPerRequest, total);
        List<Document> group = documents.subList(start, end);

        // 第二级:token 预算细分(组内若有个别超长文档,由委托器兜住)
        batches.addAll(tokenCountDelegate.batch(group));
    }
    return batches;
}

委托器就是官方的 TokenCountBatchingStrategy,token 维度的切分逻辑没必要自己重写一遍。

# 顺序是一份硬契约

BatchingStrategy.batch() 的返回值有一个容易被忽略的约定:展开后必须与原列表顺序完全一致,不能重排、不能去重。

原因是 embed(...) 按位置把返回的向量回填给文档。策略一旦重排或漏掉文档,向量就会和文档错配——而且不会报错,只会静默把错误数据写进向量库。这种 bug 事后极难查。

用 subList 顺序切片、顺序拼接天然满足这个契约;官方 TokenCountBatchingStrategy 内部用 LinkedHashMap 保序,行为一致。

# 单个文档就超预算怎么办

TokenCountBatchingStrategy 遇到「单个文档的 token 数本身就超过预算」时会抛 IllegalArgumentException——它切不动了,再切上下文就断了。

这个异常信息原本只有一句「文档太大」,看不出是哪一篇。策略里包一层,把批次内每个文档的 source 和预估 token 补进异常信息:

try {
    subBatches = tokenCountDelegate.batch(group);
} catch (IllegalArgumentException ex) {
    throw new IllegalArgumentException(buildOversizeMessage(group), ex);
}

报错会直接告诉你「第 3 个文档,来源:✅什么是聚合,什么是聚合根?.md,预估 9421 tokens」,比对着原始异常猜要省事得多。


# 配置类:EmbeddingBatchingConfig

三个参数都带默认值,开箱即用,需要调优时在 application.yml 里覆盖:

@Bean
public BatchingStrategy batchingStrategy(
        @Value("${mu-ai.rag.batching.max-documents-per-request:10}") int maxDocumentsPerRequest,
        @Value("${mu-ai.rag.batching.max-input-tokens:8000}") int maxInputTokens,
        @Value("${mu-ai.rag.batching.reserve-percentage:0.1}") double reservePercentage) {
    return new DashScopeBatchingStrategy(
            EncodingType.CL100K_BASE, maxInputTokens, reservePercentage, maxDocumentsPerRequest);
}
mu-ai:
  rag:
    batching:
      max-documents-per-request: 10     # 单次嵌入请求最多几条文本(DashScope 厂商限制)
      max-input-tokens: 8000            # 单批 token 预算
      reserve-percentage: 0.1           # 预留比例

取值上做了两个保守选择:

  • CL100K_BASE:DashScope 没有公开自己的分词规则,只能借用 OpenAI 的编码做近似估算。估算偏大只是多切几批,偏小才会真的越界,所以宁可保守。
  • 8000 而非默认的 8191:留点余量。这个项目里真正的硬约束其实是条数(10 条),token 预算只是用来兜住少数几段超长文本——实测 12 个知识库分块合计约 3000 tokens,先被触发的必然是条数上限。

# 调用方变干净了

策略接管之后,DocumentLoader 里那段手动切片循环就删掉了,直接一次性提交:

if (!allChunks.isEmpty()) {
    vectorStore.add(allChunks);
    log.info("共写入 {} 个文档分块到向量存储", allChunks.size());
}

收益不只是少了几行代码:

  1. 厂商限制只在一处声明,以后改限制只改策略;
  2. 所有经由 EmbeddingModel 的调用都自动遵守——灌库、检索、将来换别的 VectorStore 都一样;
  3. 调用方不必再关心批次大小,把整份列表丢进去就行。

这正是把横切关注点从业务代码里抽出去的价值。


# 单元测试

5 个用例,覆盖策略生效性与切批规则:

@Resource
private BatchingStrategy batchingStrategy;

@Test
@DisplayName("自定义批处理策略已生效,未被 Spring AI 默认策略覆盖")
void testCustomStrategyIsActive() {
    Assertions.assertInstanceOf(DashScopeBatchingStrategy.class, batchingStrategy,
            "批处理策略未被自定义实现覆盖,请检查 EmbeddingBatchingConfig 是否被扫描到");
}

注意这里是按接口类型注入的,而不是直接 new 一个自己测自己——只有这样才能验证「容器里最终生效的是哪个实现」。

其余四个用例:

用例 断言内容
条数超限被切批 12 个短文档 → 2 批,各批条数 10 + 2
不丢文档且保序 25 个文档切批后展开,内容与顺序与原列表完全一致
单个文档超预算抛异常 抛 IllegalArgumentException,且异常信息里带来源文件名
空 / null 输入 返回空批次,而不是抛异常

这个测试不需要 @Transactional:它只调 batch() 这个纯内存方法做切批计算,既不写向量库也不调大模型。端到端的「策略 → DashScope → 落库」验证在原来的 PgVectorVectorStoreConfigTest 里。


# 实测效果

灌库结果与改造前完全一致,说明只是把切批的职责挪了地方,没有改变行为:

  • 7 个 Markdown 文件 → 12 个分块 → 2 个批次(10 + 2)
  • 查询「什么是聚合根?」的相似度得分 0.8209,命中对应文档;另两条对照查询为 0.4438 / 0.4339,改造前后数值未变

策略内部会打两档日志:DEBUG 打每个批次的条数与预估 token(排查「为什么一次请求这么慢」时用),INFO 打汇总:

批处理完成:12 个文档 → 2 个批次(单批上限 10 个文档)

# 新增/修改文件

src/main/java/com/muzi/muaiagent/
└── rag/
    ├── config/
    │   ├── DashScopeBatchingStrategy.java    # 新增:条数 + token 双上限的切批策略
    │   └── EmbeddingBatchingConfig.java      # 新增:注册 Bean,覆盖 Spring AI 默认策略
    └── loader/
        └── DocumentLoader.java               # [修改] 删除手动 10 条切片,改为一次性提交

src/test/java/com/muzi/muaiagent/
└── rag/
    └── EmbeddingBatchingStrategyTest.java    # 新增:5 个用例,含「策略是否真的生效」的断言

pom.xml                                       # [修改] 显式声明 jtokkit 1.1.0

# 遇到的问题

# 1. jtokkit 的版本必须显式声明

DashScopeBatchingStrategy 直接引用了 com.knuddels.jtokkit.api.EncodingType,而这个库原本是经 spring-ai-tika-document-reader → spring-ai-commons 传递引入的。传递依赖能编译通过,但有两个隐患:上游一调整就会被无声移除,而且 spring-ai-bom 1.1.8 并不管理它的版本。

按「直接使用的库应显式声明」的原则补上,版本锁在原先传递引入的 1.1.0:

<dependency>
    <groupId>com.knuddels</groupId>
    <artifactId>jtokkit</artifactId>
    <version>1.1.0</version>
</dependency>

经验:只要代码里 import 了某个库的类型,它就该出现在自己的 pom.xml 里,哪怕是「反正会被传递进来」的。显式声明的代价是一行 XML,收益是不被上游的依赖调整牵连。

# 2. 覆盖机制不会告诉你它失败了

前面提过,@ConditionalOnMissingBean 的两种状态都能正常启动。写完策略后我一度怀疑「到底是我的实现生效了,还是静默退回了默认的」,因为两种情况下灌库都能跑通(12 个分块本来就没超 10 条多少)。

解决办法就是那条断言——按接口类型注入,断言实现类。它把「隐式的、静默的」配置关系变成了显式的、会失败的测试。这类场景值得形成习惯:凡是依赖条件装配、自动配置、Bean 覆盖的地方,都应该有一条「生效性断言」,否则出问题时排查成本极高。


# 心得体会

BatchingStrategy 这个扩展点本身很小,接口只有一个方法,实现也就几十行。真正值得记的是它背后的两个工程判断。

第一,横切关注点应该只有一个事实来源。 手动切片那段代码并不难写,难的是它会繁殖——今天在灌库处写一遍,明天检索处再写一遍,后天换 VectorStore 又写一遍。厂商限制一旦变化,就得全项目搜一遍。抽成策略之后,这个知识只存在于一个类里。

第二,要警惕「静默失败」的扩展点。 条件装配、自动配置、Bean 覆盖这类机制的手感很好,但它们天然缺少反馈:配置错了不报错,只是行为和预期不同。对这种地方,测试的价值不在覆盖多少分支,而在于把「生效」这件事本身变成可验证的。

下一篇准备回到知识库本身,看看换到持久化的 PgVector 之后,分块策略和检索质量还能怎么调优。


项目地址:mu-ai-agent