文档正文已经改成新价格,查询却仍可能召回旧分片。先检查存储里的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,按下面命令运行。不需要模型密钥或向量数据库服务。
<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运行。