Spring AI向量存储实战:从PGVector到RAG的完整落地
2026/9/9 3:04:34 网站建设 项目流程

Day3的复盘,重点落在Spring AI的向量存储上。社区志愿助手项目走到第三天,前面两天把Spring Boot工程和AI对话能力打通了,志愿者问“明天活动几点集合”这种问题已经能接住,但一旦问“积分兑换细则里第三条怎么说的”,模型就露馅——它没有读过社区的规则文档,全靠自由发挥。Day3要解决的就是这件事:把社区资料向量化,存进向量存储,让模型在回答之前先把相关材料“找回来”。

我先说清楚这套东西能做什么。社区志愿助手不是那种闲聊机器人,它面对的是很具体的业务问题:志愿服务时长怎么计算、临时请假怎么处理、表彰评优的条件是什么、各社区站点的服务时间有没有差异。这些问题在某个文档里都有明确说法,但没人能靠提示词把几十页材料一次性塞进上下文,也不是每次回答都要读全文。更现实的做法是:提前把资料切成小块、向量化、存进向量存储,用户提问时先用语义检索取回最相关的几段,再交给模型组织答案。这套流程就是RAG,而Day3打的就是RAG的地基。

这篇复盘尤其适合正在做Spring AI项目、准备接RAG的同学。我不会只贴代码,会把这天的完整决策路径写出来——为什么最终选PGVector、分块参数怎么定的、元数据里放了什么、相似度阈值到底调成多少,以及晚上抓到的几个反直觉问题。这些内容放到官方文档里可能只是三五行配置,但真正动手时全是坑。

1. 为什么一个志愿者问答系统会被“记忆量”卡住

1.1 社区资料不是代码,是散落在十几个文件里的规则

项目背景不复杂,我们服务的对象是某街道下辖的几个社区,志愿者和社区工作人员日常需要查大量规则。这些规则分布在《志愿者手册》、积分管理办法、活动报名说明、保险理赔指南里,有PDF、有Word、有之前群里发的通知截图转出来的文本,甚至还有几张Excel排班表。以往的查询方式是:工作人员打开文件夹,一个一个翻;志愿者则是在群里问,等人回复。

Day1和Day2做了什么铺垫?Day1把Spring Boot工程搭起来,接入了大模型接口,做了一个最原始的对话页面;Day2处理了Prompt编排,给系统规定了“你是一个社区志愿助手,回答要简洁、要给出处”,还接了几个小工具函数,比如查天气、查站点电话。但对话效果始终有个明显的瓶颈:模型不知道我们社区的规则原文。它不是不会回答,是只能靠训练数据里的普通常识瞎猜,一旦涉及具体条款,一本正经地编一个答案,比不回答更危险。

1.2 不引入向量库的方案要付出什么代价

在动手做向量存储之前,团队内部先讨论过两个“更简单”的方案。

第一个方案是关键词搜索。把PDF和Word转成纯文本,存进数据库,用户提问后把问题拆成关键词,用SQL的LIKE去匹配。这个方案几分钟就能写出来,但实际效果很差。志愿者的提问通常是“我上个月周六做了三小时环境整治,时长算不算”,这段话里的关键词“时长”“环境整治”“算不算”都很泛,LIKE匹配会把所有带“整治”两字的段落全拉出来,排序也没法做,更不用说同义词问题——有人问“积分咋算”,有人问“分值怎么累计”,关键词完全对不上。

第二个方案是把所有规则文档塞进Prompt。社区资料加起来大概二十万字,根本塞不下,就算硬塞进去,上下文窗口不够,推理成本和首字延迟都会涨到不可接受。而且大部分规则是有时效性的,比如某次活动的临时调整,如果每次都全量拼接,模型会被过期信息干扰。

所以向量存储几乎是唯一合理的路线。它的核心思路是:把每段文本通过Embedding模型转成一个高维向量,语义相近的文本在向量空间里距离也近。用户提问时同样转成向量,然后在库里做相似度检索,取回最相关的几段。这样既不用穷举关键词,也不用全量拼Prompt,检索效率和准确率都能兼顾。

1.3 Day3的目标不是“接个库”,是让助手能读懂长文档

Day3定的目标是:把《志愿者手册》、积分管理办法、活动报名须知这几份核心文档全部清洗、分块、向量化,并跑通“检索+回答”的完整链路。验收标准很简单:拿十个真实问题去问助手,它的答案引用的原文必须能从对应文档里找到,不能自由发挥。

我把Day3拆成了四个子任务:数据清洗与分块、向量库选型与接入、入库脚本、检索问答联调。看起来就是建表、调接口、写循环,但真正执行起来,前面两步花了大半天,每一步的取舍都直接影响后面的检索质量。

2. 入库前的分块与清洗,比向量化本身更花时间

2.1 社区文档的原始形态有多“脏”

千万不要拿到文件就直接切块入库。我们的资料库里有好几类格式:手册是印刷版PDF扫描后转的文字,除了正文还有页眉页脚;积分管理办法是Word文档,有目录和表格;有些活动通知是聊天记录导出成文本,一行的开头还带着“张三 10:23”这种时间戳。

最让人头疼的是表格。比如积分规则里有一个“服务类型与积分对照表”,转成纯文本后变成一行行错位的数字和文字,直接切块入库,语义信息基本就丢了。我的处理办法是统一转成Markdown,表格用Markdown的管道符语法保留下来,至少让每行内容相对完整。

清洗步骤大概是这样:先去掉页眉页脚、图片OCR产生的乱码、多余空行;然后把表格转成Markdown;再统一换行符;最后人工浏览一遍,把明显断掉的长句子手动接上。这一步没什么技术含量,但特别费时间,而且必须做。因为向量化的输入质量直接决定检索上限,脏文本进去,后面再怎么调参数都是白搭。

2.2 分块参数怎么定:从800字试验到600字

分块是向量存储里最容易“直觉失灵”的环节。块太大,语义混杂,检索时噪音多;块太小,语义不完整,模型拿到手也看不懂。

我第一版用的是Spring AI自带的TokenTextSplitter,默认每块800个Token。结果第一轮检索就有很明显的问题:积分办法里的“第三章 积分规则”是连续十几个条款,800字切下来,一条完整的规则往往被拦腰截断,检索到的是“上半句”,上下文对不上。

后来我改成“先按章节粗分,再按长度细切”的两段式做法。第一步按Markdown的标题级别把文档切成大段,标题归属于它下面那段内容,这样能保证章节结构完整;第二步把超过600字符的大段再继续切,每两个相邻块之间保留80字符的overlap,防止切断关键词或末尾信息丢失。

为什么最终定600字符?我们用的中文Embedding模型,一个汉字大概对应0.6到0.7个Token,600字符大约400多个Token,无论对Embedding接口还是后续大模型的上下文都是个比较安全的区间。块再小一点不是不行,但会显著增加库里的记录数,检索时容易把同一段内容的多个碎片都捞回来,造成冗余;块太大则精度下降。600字符配合overlap,是我们拿测试集跑下来的平衡点。

切块之后还有一个容易忽略的细节:标题信息要拼进正文开头。比如“第三章 积分规则”原本是小标题,如果单独成块,它没有足够的语义;但如果不拼到后面内容里,检索“时长怎么计算”就匹配不到“第三章 积分规则”这个上下文。我最后把标题拼在每块内容的开头,并用“| 章节:xxx”这样的前缀标记,实测检索相关性提升很明显。

2.3 元数据是向量检索的“隐藏过滤网”

我刚接触向量存储时,以为Document就是“文本+ID”,后来才发现元数据才是检索质量的隐藏杠杆。社区资料不是一类,而是分来源、分类型、分街道、分更新时间的,如果没有元数据过滤,所有文档都混在一起,用户问“城北站的积分规则”和问“城西站的积分规则”,检索结果几乎一样。

我给每个Document生成的元数据字段如下:

字段示例值用途
sourcevolunteer_handbook_2025.pdf定位原文来源
chartTyperegulation / notice / guide区分规则、通知、指南
streetchengbei / chengxi / all站点归属,all表示通用
chapter第三章 积分规则保留章节信息
updatedAt2025-11-03时效性过滤,后续可做增量

元数据还要注意类型问题。在Spring AI的过滤表达式里,字段值越简单越好,尽量用字符串和数字,不要放列表。我一开始把street设计成List,过滤时反而麻烦,后来改成单值字符串,兼容性更好。

清洗和分块做完后,我统计了一下:四份核心文档,一共切成两百三十多块,每块平均五百多字符。这个数量级对PGVector来说非常轻松,也为后面的选型提供了参考。

3. Spring AI VectorStore选型:技术时髦值与真实部署条件的权衡

3.1 Spring AI对向量库的抽象层次

Spring AI的VectorStore是这次接入的核心抽象。它把向量数据库的操作收敛成几个方法:add写入Document列表,similaritySearch做相似度查询,delete按ID或过滤条件删除。我们业务代码里只需要依赖这个接口,底层是PGVector、Redis还是Milvus,其实是可以替换的。

这个抽象的好处是,前期可以先不关心底层实现细节,把注意力放在数据准备和检索质量上。比如similaritySearch接收一个SearchRequest,里面可以设置查询文本、topK、相似度阈值、过滤表达式,这些都属于检索语义层面,和具体数据库无关。

3.2 我为什么放弃Milvus和Elasticsearch

项目启动前,团队内部列过几个候选方案:Milvus、Qdrant、Elasticsearch、Redis、PGVector。我们最终选PGVector,不是因为它最好,而是因为它最符合我们的实际情况。

Milvus功能确实强,专门为向量检索设计,支持海量数据和高并发,但它要求独立的服务,最好还有K8s或者至少Docker Compose编排,我们的社区项目服务器只有一台2核4G,上面已经跑了PostgreSQL和Nginx,再单独跑一个Milvus,资源紧张不说,运维成本也高。Qdrant和Milvus类似,也需要独立的进程,服务挂了没人及时处理。Elasticsearch检索能力全面,但内存占用大,配置复杂,这个体量的项目完全没必要。

为什么PCVector合适?我们的PostgreSQL实例本来就在正常运行,社区志愿助手的业务数据也在里面,PGVector只是一个扩展,装上后直接在现有库里建向量表,不需要新增服务,备份也能沿用原来的pg_dump方案。另外,我们数据量只有几百条,PGVector的HNSW索引完全能扛住,而且Spring AI对PGVector有开箱即用的自动配置,接入成本最低。至于未来数据量涨到百万级再去迁移Milvus,那时候项目规模允许了,重构也是值得的。

3.3 PGVector落地:依赖、配置与自动建表

引入依赖很直接:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-vector-store-pgvector</artifactId> </dependency>

这个starter会把PGVectorStore的自动配置拉进来,前提是工程里已经有数据源配置。我们的application.yml大致如下:

spring: datasource: url: jdbc:postgresql://localhost:5432/community username: volunteer password: ${DB_PASSWORD} ai: vectorstore: pgvector: table-name: vector_store index-type: HNSW distance-type: COSINE_DISTANCE dimensions: 1024 initialize-schema: true

这里有几个参数需要说明。dimensions必须和Embedding模型输出的维度一致,我们用的DashScopetext-embedding-v3默认输出1024维,填错了入库直接报错。distance-type我们用COSINE_DISTANCE,因为文本语义检索通常用余弦相似度,Spring AI计算出的score越接近1越相似。initialize-schema: true让Spring AI启动时自动建表,如果表已存在不会重复创建。

Embedding模型这一层,我们用的是Spring AI Alibaba的DashScope集成:

<dependency> <groupId>com.alibaba.cloud.ai</groupId> <artifactId>spring-ai-alibaba-starter-dashscope</artifactId> </dependency>

模型的文本向量化能力不错,而且服务在国内,接口调用稳定,不需要做网络层面的额外处理。

4. 向量入库和检索的代码实现,以及第一次跑通后的质疑问

4.1 初始化任务里写了什么

数据准备完成后,我写了一个ApplicationRunner,在应用启动时自动执行资料入库。思路是:先判断表里有没有数据,没有就执行全量入库,有就直接跳过,避免每次重启都重复写入。

判断标准很简单,直接查表:

@Autowired private JdbcTemplate jdbcTemplate; private boolean isVectorStoreEmpty() { Long count = jdbcTemplate.queryForObject( "select count(*) from vector_store", Long.class); return count == null || count == 0; }

入库逻辑就是把每份清洗好的文本分块,构造Document,带元数据,然后调用vectorStore.add

@Autowired private VectorStore vectorStore; public void loadDocuments(List<SourceDoc> sourceDocs) { for (SourceDoc sourceDoc : sourceDocs) { List<String> chunks = splitByChapterAndSize(sourceDoc.getContent()); List<Document> docs = chunks.stream() .map(chunk -> new Document(chunk, buildMetadata(sourceDoc))) .toList(); vectorStore.add(docs); } } private Map<String, Object> buildMetadata(SourceDoc doc) { Map<String, Object> meta = new HashMap<>(); meta.put("source", doc.getSource()); meta.put("chartType", doc.getChartType()); meta.put("street", doc.getStreet()); meta.put("chapter", doc.getChapter()); meta.put("updatedAt", doc.getUpdatedAt()); return meta; }

splitByChapterAndSize就是我前面说的两段式分块方法,先按标题切大段,再按600字加overlap拆小段。这里不贴全部代码了,核心是切分后要把章节信息带进chapter元数据。

第一次跑的通时,控制台输出了两百多条入库日志,我随机抽查了几条,发现内容和元数据都正常,心里一块石头落地。但接下来检索联调才是真正考验。

4.2 检索时如何让ChatClient看到这些资料

检索部分,我封装了一个RetrievalService,输入用户问题,输出相关文档片段,再交给ChatClient组织答案。

核心代码如下:

public String ask(String question) { // 1. 向量检索 List<Document> docs = vectorStore.similaritySearch( SearchRequest.builder() .query(question) .topK(4) .similarityThreshold(0.5) .build()); // 2. 拼装上下文 String context = docs.stream() .map(doc -> "[来源:" + doc.getMetadata().get("source") + "]\n" + doc.getText()) .collect(Collectors.joining("\n\n---\n\n")); // 3. 交给模型 return chatClient.prompt() .system("你是社区志愿助手,回答要简洁。优先参考提供的资料,资料中没有的不要编造。") .user("资料:\n" + context + "\n\n用户问题:" + question) .call() .content(); }

topK(4)取回四段最相关的文本,similarityThreshold(0.5)过滤掉相似度低于一半的。这两个参数第一天调了很久,后面单独讲。

遇到需要按街道过滤的场景,可以加filterExpression

SearchRequest.builder() .query(question) .topK(4) .similarityThreshold(0.5) .filterExpression("street == 'chengbei'") .build();

首次跑通时,我问了一个测试问题:“志愿服务时长怎么计算”,返回的四个片段里有两个是真相关的,另外两个跑偏了。我当时还觉得能接受,后来用十道题一测,发现整个检索的准确率并没有想象中高。

4.3 第一次跑通后我用10个问题做了“体检”

跑通不等于能用。我准备了一个小测试集,十道题覆盖积分规则、请假流程、活动报名、保险理赔四类,每道题都人工标注了应该在哪个文档里找答案。

我把每道题分别丢进similaritySearch,只做检索不看生成,然后人工判断前三个返回片段的命中情况。结果十道题里只有六道能稳定把相关片段排进前三,勉强算及格。这直接暴露了两个问题:一是分块粒度还需要细化,二是阈值选取太粗。

这一步非常建议做。大多数人接完RAG只会“感觉回答得还行”,但没有量化指标,后面优化就无从下手。我现在每天都拿这十道题回归测试,任何分块、阈值、参数调整都用它来评估,比拍脑袋靠谱得多。

5. 当天抓到的四个反直觉问题:从“数据没进去”到“检索结果像凭感觉”

5.1 启动两遍之后,向量库里多了两倍数据

第一个问题出现在入库脚本里。我把ApplicationRunner写成判断空表就入库,当天调试时重启了几次应用,有几次没等上一次完全停掉就启动了,结果两条进程同时执行,都把空库判断当成真,各写了一遍,库里多了两百多条重复数据,直接翻倍。

一开始没意识到,直到用数据查询接口看到vector_store总条数不对才发现问题。这事的教训是:不要依赖“先查再写”的非原子操作处理初始化,直接用一个明确的版本标记或者唯一的source_id约束更靠谱。我后来在vector_store上给source字段加了一个逻辑判断:入库前先查某个source是否已经存在,存在就跳过,只有全新的来源才插入。再配合手动把重复数据清掉:

delete from vector_store where id in ( select id from ( select id, row_number() over (partition by content order by id) as rn from vector_store ) t where t.rn > 1 );

这个教训很基础,但容易发生在每个人身上。尤其是项目赶进度时,初始化任务和日常业务代码不一样,它只跑一次,却没有多少人愿意为“只跑一次”设计防御逻辑。

5.2 相似度阈值不是越高越好,也不是越低越好

阈值是我当天调得最久的一个参数。第一版我把similarityThreshold设成0.7,觉得“分数高才靠谱”,结果很多正确片段被过滤掉了——比如“临时请假需要提前一天报备”和用户问的“明天临时有事怎么请假”,语义相关但字面差异大,Embedding相似度可能只有0.55,0.7的阈值直接把它们挡在门外。回答立刻劣化,模型拿不到资料,就只能含糊其辞。

反过来,把阈值调到0.3,确实什么都能查回来,但topK=4里经常混进两三条完全无关的内容,比如问积分规则,返回一段保险理赔指南。这些噪声片段会干扰模型,让它给出不准确的回答。

我最后是怎么定的?用测试集跑了一遍不同阈值下的命中分布:0.5时,十道题有七道能把相关片段排进前三;0.45和0.55都只有六道。于是定在0.5。但是这里要特别提醒:阈值不是一个全局常量,它跟你的文档类型、Embedding模型、切块粒度都相关,换了一个模型或者换了一批资料,必须重新标定。

5.3 元数据过滤条件死活不生效,原来是单双引号

街道过滤这个功能,我一开始用双引号写法:

.filterExpression("street == \"chengbei\"")

结果执行后返回空列表,我以为是没有匹配数据,后来才发现Spring AI的Filter表达式只认单引号字符串,双引号会被当成非法语法或解析失败。改成:

.filterExpression("street == 'chengbei'")

立刻正常。

这个坑很小,但查错花了我将近半小时。Spring AI官网上有专门的Filter表达式语法说明,但还是容易下意识按编程语言习惯写。遇到过滤不生效时,先排除引号问题,再看元数据字段名是否和代码里完全一致。

5.4 检索快了,但HNSW索引是我手动补的

做完基本检索后,我以为PGVector的HNSW索引是Spring AI自动建好的,也没有细查。结果某次翻数据库,发现vector_store表里完全没有索引,也就是说之前的相似度搜索一直在全表扫,只是数据量小感觉不出来。

这部分要说明:Spring AI的PGVectorStore自动配置可以指定索引类型,但建索引这个动作不一定替你执行,或者它的执行时机可能和你预期不一样。我最后手工补了HNSW索引:

CREATE INDEX IF NOT EXISTS idx_vector_store_embedding ON vector_store USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);

索引参数按PGVector默认推荐来。数据量不大的时候,有没有索引可能感觉不到差异,但等库涨到一定规模再补,重建索引的时间会更长,不如一开始就建好。补完索引后,我用EXPLAIN确认了查询走的是Index Scan,心里才踏实。

6. 向量存储的正确性验证与后续优化方向

6.1 没有“标准答案”的检索,怎么评估好坏

很多人做RAG时会有一个困惑:向量检索没有标准答案,怎么知道做得对不对?我现在的做法是给每个测试问题关联“应有来源”和“关键文本片段”,不求模型逐字命中,只求检索返回的内容和人工标注的答案段落有语义重叠。

具体地,我给每道测试题写一个简单的判定规则:相关片段是否出现在前三个结果里。连续追踪几轮下来,可以作为改进的基准线。这不是严格的离线评估框架,但对于我们这种小团队已经足够,至少能阻止“感觉变好了”这种主观判断。

我还习惯把每次检索返回的score打出来看分布。如果正确片段分数和噪声片段接近,说明分块或Embedding模型的区分力不够;如果正确片段分数明显高但阈值又挡了,说明选型阶段应该先怀疑阈值。这套分析方法帮我省了很多瞎调参数的时间。

6.2 增量更新与失效文档的清理方案

社区资料不是一成不变的,积分规则可能每季度调整,活动通知每天都在变。向量库里如果只增不改,很快就会堆满过期信息。我现在维护了一个updatedAt元数据,后续打算写一个定时任务:每个小时扫描源文件的变更时间,如果有更新,就把这个source下所有旧Document删掉,再重新切块入库。删除方法可以直接用vectorStore.delete配合过滤表达式:

vectorStore.delete(SearchRequest.builder() .query("") .filterExpression("source == 'volunteer_handbook_2025.pdf'") .build());

注意delete的过滤语义和similaritySearch类似,按元数据精确匹配即可。这个功能Day4会重点做,现在先记录在案。

6.3 Spring AI 2.0对向量存储的改动值得关注的点

项目跑在Spring AI 1.0.x的稳定版本上,但社区里已经开始讨论Spring AI 2.0的变化。我对2.0最关心的是可观测性相关的能力,比如ObservationHandler,它能把一次向量检索的耗时、召回数量、过滤条件都暴露成指标。对Day3这种靠多个参数堆出来的检索系统来说,缺乏观察手段是很大的痛点,很多问题只能靠日志和猜。

另外,2.0在RAG链路抽象上也更完善,官方文档里有了更完整的RAG实例,如果它能提供更灵活的检索器组装方式,对我们后续做混合检索、重排序会有帮助。我现在的打算是:先把1.0.x的检索效果稳定住,等2.0推出后拉一个分支做迁移评估,重点验证向量存储这块的API兼容性和可观测能力。


最后再分享一个个人体会:向量存储这层,真正的难点从来不在“存”这个动作,而在存之前和取之后。存之前要想清楚怎么清洗、切块、拼元数据;取之后要想清楚阈值、topK、过滤条件怎么配合。做Day3那天,我把大部分时间花在这两端,存和取的代码本身反而半小时就写完了。如果你正在做类似项目,建议先把测试集建起来,再来谈调优,否则很容易陷进“今天调一下,明天调一下,回头一看全靠感觉”的循环里。

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

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

立即咨询