第九章 · 藏经阁:VectorStore 抽象与 Filter DSL
下一章return fingerprint;
});( ToolSearchToolCallingAdvisor.java:214-221 )
指纹怎么算:把所有⼯具按名字排序,逐个把 name 、 summary 喂进 SHA-256,中间⽤ \0 、 \1 当字段/条⽬分隔符:
toolReferences.stream().sorted(Comparator.comparing(ToolReference::toolName)).forEachOrdered(tr -> {
digest.update(tr.toolName().getBytes(StandardCharsets.UTF_8));
digest.update((byte) 0); // field separator
digest.update(tr.summary().getBytes(StandardCharsets.UTF_8));
digest.update((byte) 1); // entry separator
});(同⽂件:329-338)
🦴 ⻣灰级细节:这⾥有两处"⽼⼿才会做"的细节。其⼀,排序让"注册顺序"不影响指纹——同⼀组⼯具换个顺序注册,指纹不变,不会⽩⽩触发重建。其⼆,为什么不⽤字
符串拼接⽽⽤⼆进制分隔符 + 哈希?注释⾃⼰点破了:⼯具名或 summary ⾥若恰好含分隔字符, "a"+"|"+"b" 和 "a|"+"b" 会撞成同⼀串,造成假命中(误判"没变"⽽漏掉重建)。 \0 / \1 这种⼏乎不可能出现在正常⽂本⾥的控制字节当分隔符,把碰撞概率压到忽略不计。还有第三层:⽤ compute() ⽽⾮先读后写,是借ConcurrentHashMap 对同⼀ key 的原⼦性,把同⼀会话的并发请求串起来,保证只有⼀个线程做 clear+reindex,不会两个线程同时清空⼜重填(注释: ToolSearchToolCallingAdvisor.java:212-213 )。短短⼏⾏,把"正确性 + 并发 + 性能"三件事⼀起办了。
名录建了就得有⼈收。会话不会⾃⼰说"我聊完了",所以索引要靠 eviction 淘汰。默认策略是 LRU,上限⼀千个会话:
private ToolIndexEvictionStrategy evictionStrategy = new LruEvictionStrategy(1000);(同⽂件:378)
每次 initializeSession 进来先 evictionStrategy.onAccess(sessionId) ,把超额的最久未⽤会话挑出来 doEvict ——清索引、删指纹、通知策略(同⽂
件:200、285-289)。另有 TtlEvictionStrategy (按闲置时⻓)、 CompositeEvictionStrategy (组合),还暴露了 evictSession(String) 让你在登出时主动释放(同⽂件:281)。
⚠ 暗礁:会话状态是真⾦⽩银的成本,且默认"够⽤但不安全"。 RAG 给典籍做检索,典籍本身是⽆状态的;可⼯具 RAG 把每个会话的索引都驻留在内存⾥——
RegexToolIndex 的 sessionIndexes 就是个常驻 ConcurrentHashMap ( RegexToolIndex.java:68 ),向量实现更是每会话⼀套 embedding。默认 LRU 上限1000:并发会话⼀旦超过,最久未⽤的会话索引会被静默清掉,下次它再来⼜得重建,体验上是"偶发性卡⼀下",排查时极难定位。更要命的是另⼀⾯——没⼈调evictSession 、⼜没配 TTL 的话,短命会话会把名额占满,真正活跃的会话反被挤出去。⽂档把 1000 这个数和"按你的峰值并发调"写进了 evictionStrategy 的Javadoc(同⽂件:434-448),等于明说:这个默认值不是给⽣产兜底的,是给你改的。把⼯具发现做成 RAG 很优雅,但它把"⽆状态的⼯具⽬录"变成了"有状态的会话资源",这笔账,接⼊⽅必须⾃⼰记。
还有⼀处⼀致性的隐忧值得点破:指纹只采 name + summary (这⾥的 summary 即⼯具 description)。若两个⼯具改了参数 schema 却没动名字和描述,指纹不变,名录不会重建——对 regex/lucene 这种只匹配名字描述的索引⽆所谓,但语义上"⼯具变了⽽索引没察觉"这件事,是这套指纹策略的边界。它赌的是"描述会跟着能⼒⼀起改",⼤多数时候成⽴,但不是铁律。
🎯 三句带⾛
1. tool-search-tool = ⼯具版 RAG:⼯具太多时只暴露⼀个 @Tool("toolSearchTool") ,模型⽤⾃然语⾔查 ToolIndex (regex/lucene/vector 三实现,按sessionId 隔离),按需召回真⼯具再注⼊下⼀轮。
2. ToolSearchToolCallingAdvisor 继承 ToolCallingAdvisor ,靠模板钩⼦缝进回路:会话初始化时建索引,每轮只暴露"师爷 + 历史搜中的⼯具",⽤name+summary 的 SHA-256 指纹判断⼯具集变化、决定是否 clear+reindex。
3. 代价是会话状态:每会话⼀套常驻索引,靠 LruEvictionStrategy(1000) (可换 TTL/Composite)淘汰;默认上限要按峰值并发⾃⾏调整,否则会出现静默重建或活跃会话被挤掉。
承上启下师爷翻的那本"⼯具名录",regex 版靠正则,lucene 版靠倒排,⽽最像样的 VectorToolIndex 靠的是按相似度调阅——它背后真正的引擎,是译馆⾥那座更⼤的建筑:藏经阁。⼯具能被语义检索,⽂档当然更能。下⼀章我们就推开藏经阁的⻔,看 Spring AI 怎么把"千百种向量库"收进⼀纸通⽤⽂牒,以及那套既要可移植、⼜要够表达⼒的检索条陈是怎么设计的——「第九章 · 藏经阁:VectorStore 抽象与 Filter DSL」。
卷四 · 藏经(RAG + 向量库 + ETL)
第九章 · 藏经阁:VectorStore 抽象与 Filter DSL
"我只管按'像不像'调阅典籍。你问我哪本最相近,我答你四本——不多不少,默认就四本。" ——若⼀座藏经阁会⾃报家⻔,它的开场⽩⼤概如此。
前⼋章,我们⼀直在译馆的"前堂"打转:前台掌柜接客、⼀排驿丞盘查、差役被派出去跑腿。那都是"当下这⼀问"的事——把客官的话翻成番邦⽅⾔,再把回函翻回来。
但译馆真正的底⽓,从来不是嘴⽪⼦,⽽是它身后那座装满典籍的藏经阁。
客官问⼀句"上个季度欧洲区退货政策改了吗",模型脑⼦⾥没有你公司的内部⽂档;它只会⼀本正经地编。要让它说⼈话,得先去藏经阁⾥按"像不像"调出⼏卷相关典籍,塞进国书⾥再递出去——这就是 RAG(检索增强⽣成)。⽽藏经阁本身,在 Spring AI ⾥有⼀个朴素的名字: VectorStore 。
这⼀章我们只解剖藏经阁这扇⻔:它对外承诺什么、怎么⽤⼀纸"检索条陈"跨库⾛天下、以及——它身上那块明晃晃的"⾮⽣产勿⽤"的牌⼦,究竟挂在哪。
⼀、⼀扇⻔,两张脸:读写分权先看藏经阁的⻔牌。它的接⼝签名,⼀句话就交了底:
// spring-ai-vector-store/.../vectorstore/VectorStore.java:40
public interface VectorStore extends DocumentWriter, VectorStoreRetriever {VectorStore 不是凭空造出来的,它同时继承了两个更⼩的接⼝: DocumentWriter (写)和 VectorStoreRetriever (读)。这不是随⼿拆的,是刻意的"读写分权"。
写的那⼀半来⾃ DocumentWriter ——还记得卷三反复提的"函数式语义别名"吗? DocumentWriter 本质是个 Consumer<List<Document>> 。所以藏经阁顺⼿就把"收⽂"这件事接上了:
// VectorStore.java:51-56
void add(List<Document> documents);@Override
default void accept(List<Document> documents) {
add(documents);
}add() 是真身, accept() 只是 Consumer 那⼀⾯的礼貌转接。这意味着⼀条 ETL 流⽔线的末端,可以直接把藏经阁当成 Consumer 接上去——reader.read() → splitter.apply(...) → vectorStore.accept(...) ,⼀⽓呵成。这条⼊藏流⽔线,是下⼀章的主菜,这⾥先按下。
删,有两副笔:
// VectorStore.java:62-84
void delete(List<String> idList); //按 ID删
void delete(Filter.Expression filterExpression); //按元数据条件删
default void delete(String filterExpression) { //字符串条件删(便⺠通道)
SearchRequest searchRequest = SearchRequest.builder().filterExpression(filterExpression).build();
Filter.Expression textExpression = searchRequest.getFilterExpression();
Assert.notNull(textExpression, "Filter expression must not be null");this.delete(textExpression);
}
注意第三个 delete(String) 是个 default ⽅法:它⾃⼰不⼲活,只是把你递进来的那句 SQL-like ⽂本( "country == 'UK'" )先借 SearchRequest 的 builder解析成 Filter.Expression ,再转交给上⾯那个真正的删除⽅法。⼀句便⺠翻译,省得每个实现类都重写⼀遍解析逻辑。
✨ 爽点时刻:逃⽣舱。藏经阁这扇⻔上,还留了⼀个不起眼的暗格:
// VectorStore.java:100-102
default <T> Optional<T> getNativeClient() {
return Optional.empty();
}可移植抽象总有覆盖不到的⻆落——⽐如某个向量库特有的 HNSW 调参、原⽣的混合检索、批量 upsert 的⾼级选项。Spring AI 没有假装"我什么都封装好了",⽽是⼤⽅留了个 getNativeClient() :默认返回空 Optional ,但具体实现可以把底层原⽣ SDK 客户端递出来,让你绕过抽象层直接操⼑。这就是译馆的诚实——通⽤⽂牒⾛天下是常态,但真要办番邦的特殊业务,它给你⼀把直通后堂的钥匙。注释⾥还诚实地补了⼀句:由于 Java 类型擦除,运⾏时拿不到 T 的真实类型,你得⾃⼰⼼⾥有
数( VectorStore.java:88-99 )。⼆、只读那张脸:最⼩权限的功能接⼝写的⼀半看完,该看读的⼀半了。 VectorStoreRetriever 是个值得单独拎出来夸的⼩设计:
// spring-ai-vector-store/.../vectorstore/VectorStoreRetriever.java:35-56@FunctionalInterface
public interface VectorStoreRetriever {
List<Document> similaritySearch(SearchRequest request);
default List<Document> similaritySearch(String query) {
return this.similaritySearch(SearchRequest.builder().query(query).build());
}
}它只有⼀个抽象⽅法 similaritySearch ,所以挂了 @FunctionalInterface ——⼀个 lambda 就能当成⼀座(只读的)藏经阁⽤。但真正的讲究在类注释⾥:
...ensuring that mutation operations (add, delete) are not exposed... following the principle of least privilege by not exposing write operations.🦴 ⻣灰级细节:把"读"从"读写"⾥抠出来,是⼀次主动的权限收窄。设想 RAG 检索那⼀段链路—— VectorStoreDocumentRetriever 之类的组件,它的职责就
是"查",压根不该有"删库"的能⼒。如果它拿到的是整个 VectorStore ,那 add/delete 这些写操作就⾚裸裸暴露在调⽤栈⾥,谁⼿滑都可能酿祸。Spring AI 的做法是:让需要"只读"的地⽅,只接受 VectorStoreRetriever 这个更窄的类型。能⼒即权限——你给它的接⼝越窄,它能闯的祸就越⼩。这是⾯向对象世界⾥"最⼩权限原则"的⼀次教科书式落地,⽽代价仅仅是多写⼀个三⾏的接⼝。
顺带点破⼀个隐喻边界:藏经阁这个⽐喻⾥,"调阅"听起来天经地义。但 similaritySearch ⼲的事⼀点都不"翻书"——它是把你的查询⽂本先 embed 成⼀个向量,再去和库⾥每条典籍的向量⽐"夹⻆"。所谓"像不像",是⼏何意义上的⽅向接近,不是语义理解。藏经阁的"管理员"其实是个只会量⻆度的⼏何学徒,别把它想得太聪明。
三、检索条陈:四个默认值⾥的脾⽓每次调阅,递进去的是⼀张 SearchRequest ——藏经阁的"检索条陈"。它的字段少得可怜,但每个默认值都藏着脾⽓:
// spring-ai-vector-store/.../vectorstore/SearchRequest.java:44-60
public static final double SIMILARITY_THRESHOLD_ACCEPT_ALL = 0.0;
public static final int DEFAULT_TOP_K = 4;
private String query = "";
private int topK = DEFAULT_TOP_K;
private double similarityThreshold = SIMILARITY_THRESHOLD_ACCEPT_ALL;
private Filter.@Nullable Expression filterExpression;四个字段:查什么( query )、要⼏本( topK )、像到什么程度才算数( similarityThreshold )、外加⼀个元数据过滤条件( filterExpression )。
默认 topK = 4 。你不指定,它就给你最相近的四卷。这个 4 没有什么神圣的道理,纯粹是个"够喂模型⼀⼝、⼜不⾄于撑爆上下⽂"的经验值——但它是写死在DEFAULT_TOP_K ⾥的全局基线,值得记住。
默认 similarityThreshold = 0.0 ,语义是"全收"。也就是说默认情况下,藏经阁不按相似度⻔槛筛⼈,只按 topK 截断——哪怕最末⼀卷其实跟你的问题⼋竿⼦打不着,只要它排进前四,照样递给你。想拦掉这种"凑数"的低分典籍,你得⼿动把⻔槛抬上去。
⽽这⾥有⼀处特别需要划重点的注释,藏着⼀个性能与语义的真相:
// SearchRequest.java:165-170// Only documents with similarity score equal or greater than the 'threshold' will be// returned. Note that this is a post-processing step performed on the client not the// server side.
public Builder similarityThreshold(double threshold) {
Assert.isTrue(threshold >= 0 && threshold <= 1, "Similarity threshold must be in [0,1] range.");⚠ 暗礁:threshold 是客户端后处理,不是服务端下推。这句"performed on the client not the server side"分量很重。它意味着:相似度⻔槛不⼀定会被翻译成向量库
的原⽣查询条件下推到服务端。在参考实现⾥(下⼀节会看到),它就是把 topK 之前的所有结果先捞回来、再在内存⾥⽤ >= ⼀⼑切。换句话说,你设 threshold =0.9 并不能减少向量库那⼀端的扫描量,只是减少了最终返回给你的条数。对于动辄百万级向量的⽣产库,这个"在哪⼀端过滤"的差别会直接影响性能和语义——可惜框架层只能给出"客户端后处理"这个保底承诺,具体某个驱动有没有把它优化成服务端下推,得你⾃⼰去翻那个库的实现。这是可移植抽象绕不开的税:为了⼀纸⽂牒⾛天下,某些优化只能让位于统⼀语义。
Builder 还顺⼿做了两道闸: topK 必须⾮负( SearchRequest.java:159 ), threshold 必须落在 [0, 1] ( SearchRequest.java:175 )——越界直接 Assert 抛错,不给你埋雷的机会。另外有个⼩⽽实⽤的设计: SearchRequest.from(原请求) 能拷⻉出⼀份 builder( SearchRequest.java:67-72 ),简单 RAG 的QuestionAnswerAdvisor 就靠它做"模板 + 只覆盖 query"——预设好 topK / threshold / filter ,每次只把⽤户那句新问题填进去。
四、Filter DSL:⼀纸条陈,跨库通⾏SearchRequest 那个最不起眼的字段 filterExpression ,撑起了这⼀章最漂亮的设计。
藏经阁不只按"像不像"调阅,还能按典籍的"标签"先圈定范围:只查 2020 年以后的、只查英国区的、只查标了 isActive 的。这些标签存在每个 Document 的metadata ⾥。问题是——Pinecone、Elasticsearch、PostgreSQL pgvector,每家的过滤语法都不⼀样。番邦各说各的⽅⾔,你总不能为每个库⼿写⼀套过滤条件吧?
Spring AI 的解法,是先定义⼀套中⽴的、谁都不偏帮的"条陈语⾔":
// spring-ai-vector-store/.../vectorstore/filter/Filter.java:81-141(节选)
public enum ExpressionType {AND, OR, EQ, NE, GT, GTE, LT, LTE, IN, NIN, NOT, ISNULL, ISNOTNULL
}
public interface Operand { }
public record Key(String key) implements Operand { } //左操作数:字段名
public record Value(Object value) implements Operand { } //右操作数:常量或数组
public record Expression(ExpressionType type, Operand left, @Nullable Operand right)
implements Operand { } //⼀个三元组:left type right
public record Group(Expression content) implements Operand { } //括号优先级整套 DSL 就这么⼏个 record: Key (字段)、 Value (值)、 Expression ( 左 操作符 右 的三元组)、 Group (括号)。它们共同实现 Operand 这个标记接⼝,于是可以互相嵌套—— Expression ⾃⼰也是 Operand ,所以 AND 的左右两边可以再是 Expression ,层层套出任意复杂的布尔树。⼀句 country == 'UK' AND year
>= 2020 ,在这套体系⾥就是:
new Expression(AND,
new Expression(EQ, new Key("country"), new Value("UK")),
new Expression(GTE, new Key("year"), new Value(2020)));注意 Expression 这个 record 还偷偷重载了⼀个双参构造器( Filter.java:129-131 ),专给 NOT / ISNULL / ISNOTNULL 这种只有⼀个操作数的⼀元表达式⽤——右边⾃动填 null 。细节克制得恰到好处。
这套中⽴条陈,有三种写法,按"⼿感"从糙到顺排:
1. ⼿写 new Expression(...) ——就是上⾯那坨,啰嗦但透明,框架内部和测试爱⽤。
2. FilterExpressionBuilder 程序化 DSL——同样⼀句话,写起来像在搭积⽊:
// FilterExpressionBuilder.java:35(类注释⾥的示例)
var b = new FilterExpressionBuilder();
var exp = b.and(b.eq("genre", "drama"), b.gte("year", 2020));
eq/ne/gt/gte/lt/lte/and/or/in/nin/isNull/isNotNull/group/not ⼀应俱全( FilterExpressionBuilder.java:60-122 ),链式拼装,IDE 还能补全,适合在代码⾥动态拼条件。
3. FilterExpressionTextParser ⽂本解析——直接写 SQL-like 字符串 "country == 'UK' && year >= 2020" ,底层⽤ ANTLR4 解析成同⼀棵
Filter.Expression 树。这也是 SearchRequest.builder().filterExpression("...") 那个字符串重载背后⼲的事( SearchRequest.java:283-287 )。最适合从配置⽂件、前端传参⾥直接吃⽂本。
三条路,殊途同归,最后都收敛成同⼀棵 Filter.Expression 树。这棵中⽴的树,才是跨库可移植的本钱。
从中⽴树到番邦⽅⾔:模板⽅法的舞台有了中⽴树,最后⼀步是把它翻译成各家向量库的原⽣语法。这活⼉交给 FilterExpressionConverter :
// spring-ai-vector-store/.../vectorstore/filter/FilterExpressionConverter.java:26-34
public interface FilterExpressionConverter {
String convertExpression(Filter.Expression expression);
}接⼝⼲净得只剩⼀个⽅法:吃⼀棵中⽴树,吐⼀段⽬标库认识的字符串。真正的⻣架在 AbstractFilterExpressionConverter ⾥,这是⼀处教科书级的模板⽅法模式:
// .../filter/converter/AbstractFilterExpressionConverter.java:95-119(节选)
protected void convertOperand(Operand operand, StringBuilder context) {
if (operand instanceof Filter.Group group) {
this.doGroup(group, context);
}
else if (operand instanceof Filter.Key key) {
this.doKey(key, context);
}
else if (operand instanceof Filter.Value value) {
this.doValue(value, context);
}
else if (operand instanceof Filter.Expression expression) {// ...校验:⾮ AND/OR/NOT/ISNULL的表达式,右边必须是 Value...
if (expression.type() == ExpressionType.NOT) {
this.doNot(expression, context);
}
else {
this.doExpression(expression, context);
}
}
}🦴 ⻣灰级细节:基类替你⾛完了整棵树,只把"叶⼦节点怎么写"留给⼦类。基类⽤ instanceof 模式匹配把递归遍历、 NOT 的语义改写、 Value 是数组时的"展开
成 [a, b, c] "逻辑( AbstractFilterExpressionConverter.java:153-168 )、甚⾄ ISO ⽇期串的⾃动归⼀化( normalizeDateString , 177-187 )统统包办了。⼦类只需要实现三个抽象钩⼦: doExpression (怎么拼⼀个⽐较式)、 doKey (字段名怎么转义)、 doSingleValue (单个值怎么转义),就能造出⼀个全新向量库的
过滤翻译官( AbstractFilterExpressionConverter.java:139-201 )。更妙的是 NOT 的默认处理:基类不会傻乎乎地把 NOT 原样塞给⼦类,⽽是先调 FilterHelper.negate(...) 把"⾮"下推、就地改写成等价的肯定式布尔树(⽐如
NOT(a == 1) → a != 1 ),再交给 doExpression ( AbstractFilterExpressionConverter.java:126-132 )。这样⼤多数库的 converter 根本不⽤操⼼ NOT这个最容易写错的算⼦——基类已经把它消解⼲净了。
于是"新增⼀个向量库的过滤⽀持"这件事,被压缩成了"写⼀个继承 AbstractFilterExpressionConverter 的⼩类、填三个钩⼦"。
PineconeFilterExpressionConverter 、 PrintFilterExpressionConverter 都是这么来的。这就是可移植抽象的全部魔法:中⽴ DSL(PIM)→ converter(模板⽅法)→ 番邦原⽣语法(PSM),和 Spring Data "⼀套 Repository 跑遍各家数据库"的思路⼀脉相承。客官写⼀遍过滤条件,换库时⼀⾏不改——这才是"⼀纸⽂牒⾛天下"在藏经阁⾥的兑现。
五、参考藏经阁:⼀个挂着"勿⽤于⽣产"牌⼦的实现抽象说了⼀路,总得有个能跑的实物。Spring AI ⾃带⼀个 SimpleVectorStore ——但它进⻔第⼀件事,就是给⾃⼰挂牌:
// spring-ai-vector-store/.../vectorstore/SimpleVectorStore.java:76-77// NOTE: This implementation is not designed for production use and should only be// used for testing or demonstration purposes.
public class SimpleVectorStore extends AbstractObservationVectorStore {// ...
protected Map<String, SimpleVectorStoreContent> store = new ConcurrentHashMap<>(); // :98它的"藏经阁"就是⼀个 ConcurrentHashMap ,key 是⽂档 ID。收⽂很直⽩——逐篇调 embeddingModel.embed(document) 算出向量,连同⽂本、metadata ⼀起塞
进 map( SimpleVectorStore.java:115-130 )。调阅那⼀段,把第三节埋的伏笔全兑现了:
// SimpleVectorStore.java:150-161
public List<Document> doSimilaritySearch(SearchRequest request) {
float[] userQueryEmbedding = getUserQueryEmbedding(request.getQuery());
return this.store.values()
.stream()
.filter(document -> doFilterPredicate(request.getFilterExpression()).test(document))
.map(content -> content
.toDocument(EmbeddingMath.cosineSimilarity(userQueryEmbedding, content.getEmbedding())))
.filter(document -> document.getScore() != null && document.getScore() >= request.getSimilarityThreshold())
.sorted(Comparator.comparing(Document::getScore).reversed())
.limit(request.getTopK())
.toList();
}⼀条 Stream 看尽藏经阁的全部脾⽓:先把查询 embed;再⽤ Filter 谓词过滤(在内存⾥逐条评估那棵中⽴树);对剩下的每⼀条算余弦相似度当 score;然后 threshold⼀⼑切、按 score 降序、 limit(topK) 截断。看清楚了吗—— threshold 那个 >= 过滤,确确实实是发⽣在内存⾥、对全表算完相似度之后,这就是第三节"客户端后处理"那句注释的活体证据。
⽽余弦相似度是它⾃⼰⼿搓的:
// SimpleVectorStore.java:270-287
public static double cosineSimilarity(float[] vectorX, float[] vectorY) {// ...⻓度校验...
float dotProduct = dotProduct(vectorX, vectorY);
float normX = norm(vectorX);
float normY = norm(vectorY);
if (normX == 0 || normY == 0) {
throw new IllegalArgumentException("Vectors cannot have zero norm");
}
return dotProduct / (Math.sqrt(normX) * Math.sqrt(normY));
}⚠ 暗礁: O(n) 全表扫描,n ⼤了就跪。这段代码的每⼀次 doSimilaritySearch ,都要把库⾥每⼀条向量都拿出来算⼀遍余弦——没有任何近似最近邻(ANN)索引,
没有 HNSW、没有 IVF,纯线性扫描。⼏百条⽂档的 demo 跑得⻜快,⼏百万条就是灾难。所以那块"⾮⽣产勿⽤"的牌⼦绝⾮谦辞:它是给你⼊⻔、写单测、跑 demo⽤的真⼼话。⽣产环境请⽼⽼实实换 pgvector、Redis、Qdrant 这类带索引的专业库——⽽换库时,因为有第四节那套 Filter DSL,你的过滤逻辑⼀⾏不⽤动。这恰好反过来印证了可移植抽象的价值。
它倒也不是⼀⽆是处:⽀持 save(File) / load(File|Resource) 把整个 map 序列化成 JSON 落盘再读回( SimpleVectorStore.java:174-239 ),⼩数据集做个"准持久化的本地知识库"挺顺⼿。
六、观测的隐形外⾐:模板⽅法⼜⼀次出场
最后回头看⼀个⼀直被略过的细节: SimpleVectorStore 继承的不是 VectorStore ,⽽是 AbstractObservationVectorStore 。这个中间层做的事,值得单独点⼀句——它是把 Micrometer 观测,像隐形外⾐⼀样套在每个真实操作之外:
// .../vectorstore/observation/AbstractObservationVectorStore.java:74-85@Override
public void add(List<Document> documents) {
validateNonTextDocuments(documents);
VectorStoreObservationContext observationContext = this
.createObservationContextBuilder(VectorStoreObservationContext.Operation.ADD.value())
.build();VectorStoreObservationDocumentation.AI_VECTOR_STORE
.observation(this.customObservationConvention, DEFAULT_OBSERVATION_CONVENTION,
() -> observationContext, this.observationRegistry)
.observe(() -> this.doAdd(documents));
}⼜是模板⽅法:对外的 add / delete / similaritySearch 由基类实现,它们的唯⼀⼯作就是起⼀个 Observation、把真正的活⼉包在 observe(() ->
this.doXxx(...)) ⾥;⼦类只需实现抽象的 doAdd / doDelete / doSimilaritySearch ( AbstractObservationVectorStore.java:149-174 )。于是任何⼀个新向量库,只要继承这个基类,就⾃动⽩嫖了全套指标与链路追踪——埋点这种横切关注,作者⼀次都不让⼦类操⼼。 similaritySearch 还会把 query 和返回的
documents 都写进 VectorStoreObservationContext ( 128-143 ),给账房留下完整账⽬。add ⼊⼝处那道 validateNonTextDocuments 校验,引出本章最后两处真实的批评:
// AbstractObservationVectorStore.java:87-97
private void validateNonTextDocuments(List<Document> documents) {
for (Document document : documents) {
if (document != null && !document.isText()) {
throw new IllegalArgumentException(
"Only text documents are supported for now. One of the documents contains non-text content.");
}
}
}⚠ 暗礁其⼆:藏经阁当前只收"⽂字典籍"。 Document 模型本身是 text/media ⼆选⼀的(下⼀章细说),但 add 这道关卡明确写着 "Only text documents are
supported for now"——⼀旦发现 media ⽂档,当场 IllegalArgumentException 拒之⻔外。在这个号称"多模态"的时代,向量库还进不去图⽚/⾳频⽂档,是个挺扎眼的短板。 for now 这个词暴露了它的过渡态⼼境。
还有⼀处过渡态更微妙,值得点破:
// AbstractObservationVectorStore.java:162-167
protected void doDelete(Filter.Expression filterExpression) {// this is temporary until we implement this method in all concrete vector stores,// at which point this method will become an abstract method.
throw new UnsupportedOperationException();
}基类⾥"按 Filter 删除"这个钩⼦,默认实现是直接抛 UnsupportedOperationException 。注释⾃⼰交代了原委:这是临时态,等所有具体库都实现了它,才会升格成抽象⽅法、强制每个⼦类实现。换句话说,接⼝上 VectorStore.delete(Filter.Expression) 是写死必须有的(第⼀节那个⾮ default ⽅法),但基类层⾯留了个会爆炸的兜底——你对着⼀个还没实现它的向量库调"按条件删除",运⾏时才会发现此路不通。这是⼤型框架演进期典型的"接⼝先⾏、实现追赶"的尴尬:契约已经许诺,履约却参差不⻬。有意思的是,作为参考实现的 SimpleVectorStore 反倒⽼⽼实实把它实现了( SimpleVectorStore.java:139-147 ,内存⾥筛出匹配 ID 再删)——模范⽣交了作业,正式⽣还在拖⽋。⽤之前,务必查清你那家向量库的版本到底兑现了没有。
🎯 三句带⾛
1. VectorStore extends DocumentWriter + VectorStoreRetriever ,把读写拆成两个接⼝实现最⼩权限;只读处只接 VectorStoreRetriever ,写能⼒压根不暴露。
2. SearchRequest 默认 topK=4 、 threshold=0.0 (全收),且 threshold 是客户端后处理——不保证下推到向量库服务端,⼤库上要留意性能与语义。
3. Filter DSL ⽤中⽴的 Expression/Key/Value/Group record 表达,经 FilterExpressionConverter (模板⽅法)翻成各库原⽣语法,实现跨库可移
植; SimpleVectorStore 是 O(n) 内存参考实现,仅供测试,且基类的 doDelete(Filter) 仍是过渡态的 UnsupportedOperationException 。承上启下这⼀章我们站在藏经阁⻔⼝,看清了它对外的承诺:怎么按"像不像"调阅、怎么⽤⼀纸跨库通⾏的检索条陈圈定范围、以及那块"勿⽤于⽣产"的牌⼦挂在何处。但有个问题我们⼀直绕着⾛——那些被调阅的"典籍"是从哪⼉来的? ⼀份散装的 PDF、⼀篇 Markdown、⼀个爬来的⽹⻚,怎么被裁切成⼤⼩合宜的 Document 、算出向量、最后归档⼊阁?
⻔⾥的故事,才刚刚开始。下⼀章「第⼗章 · ⼊藏流⽔线:Document 与 ETL」,我们就跟着⼀份外来⽂书,⾛完从"读进来"到"切碎了"再到"⼊了阁"的全程,看看Document 这个数据载体的⼆象性,和那条 Reader → Splitter → Writer 的函数式流⽔线,究竟是怎么把世间杂乱⽂字,炼成藏经阁⾥可供调阅的整⻬典籍。