Spring AI文档更新后仍命中旧内容:核对分片ID、来源和版本

0 阅读4分钟

文档正文已经改成新价格,查询却仍可能召回旧分片。先检查存储里的ID和版本,再判断是不是模型回答出了问题。

下面固定JDK 21、Spring Framework 7.0.8、Spring AI 2.0.0,使用真实SimpleVectorStore。嵌入部分是返回相同三维向量的测试替身,让所有候选相关性相同,专门验证生命周期与过滤;没有调用真实嵌入模型或聊天模型,不声称测了生成正确率。

两版同源文档,加一份其他来源

分片IDsourceversion文本
price-v1-0price-sheetv1课程A价格:100元
price-v2-0price-sheetv2课程A价格:120元
other-v2-0other-sheetv2其他课程价格:300元

源文档标识与分片ID分开。v1和v2使用不同分片ID,新建v2不会自动告诉存储删除v1;如果分片策略或数量变化,也不能假设一次按ID覆盖能清掉旧版剩余分片。

来源、分片ID与版本共同决定本次检索范围

完整实验

保存pom.xml与DocumentVersion.java,按下面命令运行。不需要模型密钥或向量数据库服务。

<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>cn.fengge</groupId><artifactId>article-demo</artifactId><version>1.0</version>
  <dependencies>
    <dependency><groupId>org.springframework</groupId><artifactId>spring-context</artifactId><version>7.0.8</version></dependency>
    <dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-vector-store</artifactId><version>2.0.0</version></dependency>
  </dependencies>
</project>
import java.util.*;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.*;
import org.springframework.ai.vectorstore.*;

public class DocumentVersion {
    // 测试向量刻意相同,只检查文档生命周期;不测试真实嵌入模型的语义能力。
    static class FixedEmbedding implements EmbeddingModel {
        public float[] embed(Document document) { return new float[]{1, 0, 0}; }
        public EmbeddingResponse call(EmbeddingRequest request) {
            List<Embedding> rows = new ArrayList<>();
            for (int i = 0; i < request.getInstructions().size(); i++)
                rows.add(new Embedding(new float[]{1, 0, 0}, i));
            return new EmbeddingResponse(rows);
        }
        public int dimensions() { return 3; }
    }
    static Document chunk(String version, String price) {
        return new Document("price-" + version + "-0", "课程A价格:" + price + "元",
                Map.of("source", "price-sheet", "version", version));
    }
    static List<Document> search(SimpleVectorStore store, String filter) {
        var builder = SearchRequest.builder().query("课程A多少钱").topK(10).similarityThresholdAll();
        if (filter != null) builder.filterExpression(filter);
        return store.similaritySearch(builder.build());
    }
    static Set<String> ids(List<Document> docs) {
        var out = new TreeSet<String>();
        for (var d : docs) out.add(d.getId());
        return out;
    }
    static void check(boolean ok) { if (!ok) throw new AssertionError("检索结果不符"); }
    public static void main(String[] args) {
        var store = SimpleVectorStore.builder(new FixedEmbedding()).build();
        var v1 = chunk("v1", "100");
        var v2 = chunk("v2", "120");
        store.add(List.of(v1));
        store.add(List.of(v2)); // 新ID追加,不会自动删除旧ID。
        store.add(List.of(new Document("other-v2-0", "其他课程价格:300元",
                Map.of("source", "other-sheet", "version", "v2"))));
        var mixed = ids(search(store, "source == 'price-sheet'"));
        System.out.println("append=" + mixed);
        check(mixed.equals(Set.of(v1.getId(), v2.getId())));
        var tooBroad = ids(search(store, "version == 'v2'"));
        System.out.println("only-version=" + tooBroad);
        check(tooBroad.equals(Set.of(v2.getId(), "other-v2-0")));
        var current = ids(search(store, "source == 'price-sheet' && version == 'v2'"));
        System.out.println("version-filter=" + current);
        check(current.equals(Set.of(v2.getId())));
        // 仅移除本来源旧版分片,不删除其他来源或当前版本。
        store.delete(List.of(v1.getId()));
        var afterDelete = ids(search(store, "source == 'price-sheet'"));
        System.out.println("delete-old=" + afterDelete);
        check(afterDelete.equals(Set.of(v2.getId())));
        check(ids(search(store, "source == 'other-sheet'" )).equals(Set.of("other-v2-0")));
    }
}

命令以Windows PowerShell为例;Linux/macOS运行依赖程序时,将类路径中的分号换为冒号。

mvn dependency:copy-dependencies -DoutputDirectory=deps
javac -encoding UTF-8 -cp "deps/*" DocumentVersion.java
java -cp ".;deps/*" DocumentVersion

本机这次运行的标准输出:

append=[price-v1-0, price-v2-0]
only-version=[other-v2-0, price-v2-0]
version-filter=[price-v2-0]
delete-old=[price-v2-0]

先读分片集合,再讨论模型回答

append里有同一来源的两个版本,说明“追加成功”不能证明“旧内容已退出当前查询”。本例topK=10且候选很少,故意让两版都能进入,不把排序偶然性当成结论。

only-version同时出现other-v2-0与price-v2-0,说明仅按版本过滤不表达来源范围。version-filter同时限制source与version,结果才是当前来源的新分片。这个过滤不是权限系统,真实项目还应在服务端根据身份决定可访问范围。

delete-old删除已知v1分片后重新查询,当前来源只剩v2;程序还断言其他来源没有被删除。实际工程需要维护“来源—版本—分片ID”的对应关系,不能凭一次固定ID演示推断任意文档都已清理完整。

两种更新方式,各自还缺哪些验证

按版本过滤可以先写新版本、核对可检索结果,再切换生效版本;要保留切换状态并确保所有查询都使用正确来源和版本。旧版本何时回收另行决定。

按旧ID删除则需要确认删除范围、失败重试和新分片写入时机;如果先删除、再写入,中间可能出现空结果。使用哪条路径取决于业务要求与后端能力,不能仅凭本地存储同时宣称跨系统原子更新。

SimpleVectorStore适合本次内存实验;远端索引的可见时间、元数据过滤能力与删除行为,要对选用实现重新验证。如果检索文本已经全是新内容,答案仍旧,再去查缓存、提示词与模型输出。本文没有生成回答,不把检索测试冒充端到端AI评测。

你们更新文档时,能否同时找到旧分片ID、当前生效版本和一次实际召回记录?先有这三项,才容易说清到底更新到了哪一步。

参考:Spring AI VectorStore接口与文档增删检索。当前参考页与本例依赖版本可能不同,程序已固定2.0.0运行。