☰
第13章:向量检索入门——SimpleVectorStore
2026/10/14 13:25:50 网站建设 项目流程

上章思考题答案

  1. 编号若被单独检索,用MetadataMode.EMBED会让「R-01」靠元数据命中,正文语义被冲淡。本章希望用户口语命中正文,编号只在命中之后展示,所以嵌入用MetadataMode.NONE。
  2. 0.31 和 0.29 都低,谁最大也不能说明命中了制度。只取最大会把不相关的句子送去改写,模型仍可能说成店规。阈值要在「谁最大」之外再判断:低于阈值就当没有命中,不要改写。

1. 项目背景

1.1 业务场景

三条制度已经能两两算出余弦,但用户问句一来,还得自己写循环去比。邻里仓要把退款、超时、库存三条放进一个可查询的集合。用户问「怎么退款」,应取回退款条款。用户问「库存不够怎么办」,不应把退款条款当作答案。低分的片段如果也塞进提示词,模型会把无关的句子说成制度。

本章用SimpleVectorStore做内存余弦检索。SearchRequest上同时有topK和similarityThreshold。这是单机和测试用的实现,两个应用实例各有一份内存,不会自动同步。邻里仓这一步只放三条制度。退款问题必须落到退款条款,库存问题必须落到库存条款,天气问题在阈值生效后必须是空列表。空列表由应用回固定句子,不交给聊天模型发挥。

1.2 痛点

自己写的双重循环能算三条,第三十条时每个人都复制一份,阈值还各写各的。有人topK=4却不设阈值,库里只有三条时,不相关的问题也会取回三条,模型从中挑一句最顺口的,经常挑错。也有人把阈值设成 0.99,正常退款问题一条都取不回,接口变成空回答。

没有检索对象时的路径:手写循环 → 阈值不统一;或只设 topK → 低分也进上下文;或阈值过高 → 有制度也答不出。答不出和答错不是一回事。阈值过高时用户看到「没有对应条款」,还可以转人工。阈值过低时用户看到一条像模像样的制度,实际上是库存规则被说成退款规则,客诉更难追溯。所以空列表要保留,低分片段不能进提示词。

是

否

用户问题

SimpleVectorStore

按余弦排序

分数不低于阈值?

最多 topK 条

空列表

不把低分送进提示词


2. 项目设计

小胖说:「这不就是抽屉吗?标签贴好,问退款就拉开退款那一格。多拉几格总比拉空了强,用户好歹有东西看。」

小白问:「多拉的那几格如果是库存和营业时间呢?模型会不会从营业时间里编一条退款规则?抽屉在谁的桌子上?另一台机器上的抽屉是空的吗?」

小胖又说:「阈值我设 0,相当于都要。TopK 设 1,只要最像的。」

小白追问:「最像的一条如果只有 0.2,TopK 为 1 仍然会返回它。0 阈值和 TopK 1 合在一起,等于永远有答案。这和没有检索有什么区别?」

大师把两个旋钮分开:「VectorStore.add把文档嵌进内存。similaritySearch接收SearchRequest。topK是最多返回几条,默认 4。similarityThreshold是最低分,默认 0,也就是SIMILARITY_THRESHOLD_ACCEPT_ALL,低于这个分数的文档被丢掉,即使名次很靠前。两个条件同时生效:先按分数过滤,再截断到 topK。SimpleVectorStore.builder(embeddingModel)使用第 12 章的TransformersEmbeddingModel。它把向量放在进程里的Map,save和load可以写成文件,但另一个 JVM 不会自动看见。重启后要重新add,或者load上次的文件。多实例共享不是它的能力。」

技术映射:抽屉是SimpleVectorStore,最多拿几份是topK,分数不够就不拿是similarityThreshold。隔壁桌子的抽屉是另一个进程。

小白又问默认值:「topK不写的时候是 4,阈值不写是 0。我若只写了查询字符串,库里后来加到 20 条,一次查询会带回 4 条最低也包含不太像的吗?」

小胖说:「默认就该能用,少写两个参数。」

小白说:「默认阈值 0 的意思是来者不拒。20 条里凑满 4 条很容易,后三条可能和问题无关。少写参数等于选择了最松的过滤。」

大师把默认值说成显式选择:「演示和测试都把topK和similarityThreshold写出来。写 0 也可以,但那是有意接受全部分数,不是忘了。生产路径不允许只传查询字符串。代码评审看到similaritySearch(String)这种只传句子的调用,就要求改成带两个参数的SearchRequest。」

小白问分数从哪来:「返回的Document.getScore()是距离还是相似度?我按距离去设 0.2,会不会把好结果丢掉?」

小胖说:「分高就好,我看打印。」

小白说:「不打印就写进测试的人,会把第一次看到的 0.73 当成永恒标准。模型或文本改一个字,0.73 就红了。」

大师规定断言:「SimpleVectorStore的分数是余弦相似度,越大越相关。测试先不锁死阈值的绝对数字。退款问题的首条clauseId必须是R-01或R-02这一类退款条款,不得是S-01。库存问题的首条必须是S-01,不得是退款条款。然后再做阈值实验:把阈值抬到高于无关文档、低于目标文档的位置,无关问题的结果应为空列表。空列表不要补一条『最像的』。打印分数只供人选阈值,不写进assertThat(score).isEqualTo。」

技术映射:分数是相似度。空抽屉就是空列表,不要从地上捡一张最不像的纸塞回去。测试锁条款编号,不锁某一次打印出来的小数。

小白问天气题:「天气题返回空,用户会不会觉得机器人坏了?要不要至少回一条最接近的,并注明不太确定?」

大师说:「注明不确定仍然是在引用一条不相关的制度。空列表就用固定句子说明手册里没有对应条款。不确定的引用比沉默更容易被当成承诺。这句固定句子不经过模型。」

小胖问入库:「三条字符串 add 进去就行了吧?编号我写在正文开头。」

小白问:「add 的时候嵌的是正文还是带元数据的格式化文本?和上一章的MetadataMode.NONE还一致吗?库是单例的话,测试之间会不会串数据?」

大师对齐嵌入:「add时由注入的EmbeddingModel决定嵌什么。模型按第 12 章用MetadataMode.NONE构造,编号仍只放元数据。测试每个用例新建一个SimpleVectorStore,或者在用例开始时使用新的实例,不要往 Spring 单例里反复 add 同一条。重复 add 会让 TopK 里出现两份 R-01,看起来像召回很稳,其实是脏数据。」

技术映射:贴标签发生在add。抽屉每个测试各用一个,避免上一桌的纸还在里面。单例抽屉会让第二道题看见第一道题留下的副本。

小白问文件:「save出去的文件能不能代替下一章的手册?里面已经有向量了。」

大师说:「那个文件是索引快照,不是手册原文。手册改一个字,快照不会跟着变。本章可以演示save和load,但不要把快照当制度来源。制度来源仍是文档正文。换嵌入模型之后,快照要删掉重建。」


3. 项目实战

3.1 环境准备

沿用第 12 章的TransformersEmbeddingModel和缓存目录。增加spring-ai-vector-store,版本交给 BOM。本章检索测试不调用 DeepSeek。三条文档:

  • R-01未发货订单可以申请退款。
  • R-02已发货订单不予退款。
  • S-01库存不足时暂停发货并通知用户。

再准备三句问话:「怎么退款」「货已经发出去了还能退吗」「库存不够怎么办」。外加一句无关问话「今天天气怎么样」,用来证明阈值不是摆设。三句业务问话和这句无关问话必须分四次搜索。不要拼成一条长查询,否则向量会同时靠近退款和库存,首条编号失去意义。每个测试方法自己builder一个新的SimpleVectorStore,在方法内add这三条。不要把库做成 Spring 单例再在用例之间累积。

3.2 分步实现

步骤 1:建库并添加文档

步骤目标:三条制度可被检索,且各有条款编号。

TransformersEmbeddingModelembedding=newTransformersEmbeddingModel(MetadataMode.NONE);embedding.setResourceCacheDirectory("/tmp/linlicang-onnx");embedding.afterPropertiesSet();SimpleVectorStorestore=SimpleVectorStore.builder(embedding).build();store.add(List.of(unshipped,shipped,stock));

运行结果(文字描述):add返回后,用很低的阈值搜索「退款」,列表非空,元数据里能看到clauseId。没有连接外部数据库。

可能的坑:嵌入模型没有afterPropertiesSet()就add,异常发生在入库而不是查询,容易被当成向量库的故障。先单独embed一句成功,再add。另一个坑是文档正文为空,写入后搜索永远不命中。空正文不要入库。

步骤 2:TopK 而不设有效阈值

步骤目标:看清默认阈值会把低分也留下。

List<Document>loose=store.similaritySearch(SearchRequest.builder().query("今天天气怎么样").topK(3).similarityThreshold(SearchRequest.SIMILARITY_THRESHOLD_ACCEPT_ALL).build());

运行结果(文字描述):天气问题仍返回最多 3 条,因为阈值是 0。首条分数通常明显低于「怎么退款」命中 R-01 的分数。把两条查询的首条分数打印出来。这个结果用来说明:只有 topK 时,不相关问题也有「答案」。

可能的坑:把这一步的返回值直接拼进提示词。天气问题会得到一条退款或库存制度,模型把它说成相关规定。演示完就要进入下一步的阈值。

步骤 3:用阈值丢掉低分

步骤目标:退款命中退款条款,库存不命中退款条款,无关问题为空。

先打印「怎么退款」首条分数,记为高分;打印天气问题首条分数,记为低分。阈值取两者中间,例如高分 0.7、低分 0.3 时,用 0.5。数字以你本机打印为准,写进测试前用相对关系保护:

List<Document>refundHits=store.similaritySearch(SearchRequest.builder().query("怎么退款").topK(2).similarityThreshold(0.5).build());List<Document>stockHits=store.similaritySearch(SearchRequest.builder().query("库存不够怎么办").topK(2).similarityThreshold(0.5).build());

运行结果(文字描述):退款问题的首条clauseId是R-01或R-02,不是S-01。库存问题的首条是S-01。若 0.5 在你机器上把退款也滤掉了,把打印出来的退款分数和库存分数重新取中点,不要为了通过测试把阈值改回 0。

可能的坑:阈值写成 0.5 之后换了嵌入模型,全军覆没。处理:断言条款编号的相对关系,阈值常量集中在一个地方,并在日志里打首条分数。另一个坑是topK(2)时退款问题第二名是库存。拼提示词时不要无脑拼接全部命中。可以只取首条,或要求第二条分数也过同一阈值并且条款族一致。本章测试看首条。

步骤 4:空结果不回填最相似

步骤目标:低于阈值就是没有依据。

天气问题在同一阈值下应返回空列表。业务代码遇到空列表时返回「手册里没有对应条款」,不要再发一次similarityThreshold(0)去捞第一名。

运行结果(文字描述):空列表。没有 DeepSeek 调用。固定文案可以由应用直接返回。

可能的坑:检索为空后仍调用聊天模型,并写「请根据常识回答」。模型会编一条退款规则。空结果的分支里不要创建ChatClient调用。

三道问题要分成三次搜索,不要把三句话拼成一条查询。拼在一起的向量会同时像退款和库存,首条条款失去意义。每道题打印首条的clauseId和getScore()。退款题的编号落在 R-01 或 R-02,库存题必须是 S-01。天气题在阈值生效后长度为 0。若天气题仍有结果,说明阈值还低于天气分数,把阈值上调到天气分数之上、退款分数之下,然后三道题一起重跑。只改天气题的阈值、退款题另用 0,会让测试看起来很绿,生产路径却仍然用 0。

测试之间不要共用一个已经add过的库。重复添加后,TopK 里两份 R-01 会挤掉 S-01,库存题看起来像检索坏了,其实是脏数据。每个测试方法新建SimpleVectorStore。本章不调用 DeepSeek,构建机器只要能加载上一章的 ONNX 缓存即可。四次搜索的首条编号和天气题的空列表,是这一章的全部验收。退款题允许 R-01 或 R-02,因为两句都在谈退款。库存题不允许这种放宽,必须是 S-01。放宽库存题会让「不命中退款制度」这句目标落空。天气题必须和业务题使用同一个阈值常量。为天气题单独写一个更低的阈值,等于把「无关问题也能取出条款」又放了回去。常量放在测试和业务同一处,打印分数只帮助调整这一处。调整之后四道题一起重跑,不能只重跑天气题。退款题若在新阈值下变空,说明中点取高了,再往退款分数靠一靠,而不是退回 0。

3.3 完整代码清单

PolicyStore和PolicySearchTest。源码对照:

  • spring-ai-vector-store/.../VectorStore.java
  • spring-ai-vector-store/.../SearchRequest.java
  • spring-ai-vector-store/.../SimpleVectorStore.java

示例继续放在samples/linlicang-aftersale-boot。目录落地前以本章代码块为准。

3.4 测试验证

@TestvoidrefundAndStockHitDifferentClauses()throwsException{SimpleVectorStorestore=newStore();DocumentrefundTop=first(store,"怎么退款");DocumentstockTop=first(store,"库存不够怎么办");assertThat(refundTop.getMetadata().get("clauseId")).isIn("R-01","R-02");assertThat(stockTop.getMetadata().get("clauseId")).isEqualTo("S-01");assertThat(store.similaritySearch(SearchRequest.builder().query("今天天气怎么样").topK(2).similarityThreshold(0.5).build())).isEmpty();}

first使用同一阈值。若天气用例因为本机分数不够低而未空,先打印分数再调整常量,不要删掉空列表断言。

mvn -Dtest=PolicySearchTest test

不需要DEEPSEEK_API_KEY。


4. 项目总结

4.1 优点与缺点

对比项手写双重循环SimpleVectorStore
TopK 和阈值各写各的SearchRequest
存储自己的 List进程内 Map
重启自己负责内存消失,可 save 文件
多实例没有共享仍然没有共享
外部依赖无无数据库,要嵌入模型

优点:

  1. 退款和库存可以落到不同条款。
  2. 阈值能把无关问题收成空列表。
  3. 不需要单独部署向量数据库。
  4. 测试可以每个用例一个新库。
  5. 分数含义和上一章的余弦一致。

缺点:

  1. 进程重启后内存清空。
  2. 两个实例各查各的,结果可能不同步。
  3. 数据量上去之后,全量扫描不合适。
  4. 阈值是常数,换嵌入模型就要重选。
  5. 默认阈值 0 会让不相关问题也有结果。

4.2 适用场景

适用:

  1. 单机演示和单元测试。
  2. 制度只有几十条。
  3. 需要同时练习 topK 和阈值。
  4. 检索结果要带clauseId元数据。
  5. 空结果必须拒绝编造。

不适用:

  1. 多实例生产还指望索引一致。要换外部向量库,本章的VectorStore接口可以留着。
  2. 用默认阈值 0 直接回答用户。那会把低分片段送进模型。

4.3 注意事项

  • 新建库给每个测试,避免重复 add。
  • 先打印分数,再把阈值放在高低分之间。
  • 空列表是合法结果,不要自动降阈值重试。
  • save出的文件含向量,不要提交到仓库。

4.4 常见踩坑

  1. 库存问题的答案是退款条款。根因是只看 topK、阈值是 0,而且提示词拼接了全部结果。处理:看首条条款编号,低于阈值的不拼接。
  2. 退款问题突然一条都没有。根因是阈值抄了别的模型的 0.9。处理:用本机高分和低分取中点。
  3. 发布后有的实例能答、有的实例不能。根因是只在一个进程里add了。处理:启动时每个实例都从同一份手册重建,或改用共享库。重建之前不要承诺多实例结果一致。

4.5 思考题

  1. topK设为 4,库里只有 3 条,阈值是 0。天气问题会返回几条?这 3 条若拼进提示词,模型最容易犯哪种错?
  2. 测试把SimpleVectorStore做成 Spring 单例,第一条用例 add 了 R-01,第二条用例再 add 一次。TopK 里为什么会出现重复条款?用例结束时应该怎样隔离?

答案在第 14 章开头。

延伸阅读与资源

10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询