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

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

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

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

分片ID source version 文本
price-v1-0 price-sheet v1 课程A价格:100元
price-v2-0 price-sheet v2 课程A价格:120元
other-v2-0 other-sheet v2 其他课程价格:300元

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

完整实验

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

xml 复制代码
<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>
java 复制代码
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运行依赖程序时,将类路径中的分号换为冒号。

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

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

text 复制代码
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运行。

相关推荐
曹牧26 分钟前
Java:`tools.jar`
java·开发语言·jar
QuZhengRong27 分钟前
【News】2026.10.16 – 10.21 AI 科技周报
人工智能·科技·openai·谷歌·新闻
aibo86030835 分钟前
基于 Spring Boot + Vue3 的爱心众筹管理系统设计与实现
java·spring boot·后端
SimonKing36 分钟前
OpenCode免费模型直接给`Workbuddy`用,一键导入
java·后端·程序员
Tsuki_tl38 分钟前
【优选算法:双指针】——复写零题解
java·c++·算法·leetcode·数组·双指针·复写零
qq_252941316838 分钟前
不同成熟度番茄果实多模态图像数据集 | 番茄成熟度 多模态检测 可见光深度红外 智慧农业 语义分割 目标检测9233期
人工智能·yolo·目标检测·目标跟踪·番茄多模态
我命由我1234539 分钟前
Python 开发 - Python 项目部署(Windows 与 Linux)
java·linux·windows·后端·python·java-ee·python3.11
matlab代码42 分钟前
基于BP神经网络手写字母识别(单一)【源码79期】
人工智能·深度学习·神经网络
小羊没烦恼!44 分钟前
TypedSql:在 C# 类型系统上实现一个 SQL 查询引擎
java·linux·运维·服务器·开发语言·数据库·mysql