Spring Boot接入AI搜索:向量化、RAG链路与调参实战指南
2026/9/10 7:29:41 网站建设 项目流程

上个月接到一个需求,要把官网的商品搜索升级成AI搜索,用户问“能跑马拉松的防水运动手表”,系统不能只返回名字里带“跑步”的商品,还得理解“防水”“长续航”“轻量”这些隐含条件。当时第一反应是上ES,但看了一眼索引里的数据形态和团队维护成本,还是决定用Spring AI把向量数据库和RAG接进来。这篇就聊聊这次改造的完整思路,重点在数据向量化、向量检索、RAG链路和调参经验,适合已经在用Spring Boot、想给项目加语义搜索能力的朋友,我尽量把每一步为什么要这么做讲清楚。

先说结论:RAG本身不是什么高深技术,核心就是把“用户问题”和“已有资料”都转成向量,在向量数据库里做相似度检索,再把检索结果作为上下文丢给大模型去组织答案。但真正落地时,难点全在细节里——Embedding模型选哪个、文档怎么切、相似度阈值设多少、检索结果怎么拼进Prompt,任何一环偷懒,效果都会垮。

1. 先把问题拆清楚:LIKE查询和ES为什么解决不了语义搜索

1.1 一个真实的搜索需求变化过程

我们当时的商品库大概有3万多条SKU,每条记录里有名称、品类、属性JSON、详情描述。老的搜索就是一条SQL:

SELECT * FROM product WHERE name LIKE '%跑步%' OR category LIKE '%跑步%' ORDER BY sales_volume DESC LIMIT 20;

这套东西在用户搜索词比较简单时还能用,但业务方提的需求越来越离谱,比如“适合夜跑、重量轻、有GPS定位的手表”。这个Query里没有任何一个词能和商品名称完全匹配,但你又不能说它不该返回结果。后来加过MySQL全文索引,效果稍微好一点,但同义词、跨字段语义组合、英文缩写这些问题一个都没解决。

1.2 三种搜索扩展路线的取舍

我把当时考虑过的方案拉了一张对比表:

方案核心思路优点问题
传统关键词检索(LIKE/全文索引)词面匹配实现简单、排序可控无法理解同义词、无法处理跨字段语义
全文搜索引擎(Elasticsearch)分词+倒排索引分词可定制、聚合能力强仍然解决不了语义泛化,需要维护集群
向量检索+重排序语义向量相似度匹配能处理同义改写、跨字段组合Query需要额外搭建Embedding与向量存储链路

ES确实是很多团队的默认选项,但大家容易忽略一点:ES的词项匹配本质还是“字符串层面”的相似,分词器再强也猜不到“马拉松”和“全马”在语义上是同一个意思。而向量检索直接把文本映射成高维空间里的坐标,语义相近的文本天然距离近,这一步就省掉了大量同义词维护工作。

RAG在这条路线里的位置,可以理解成“向量检索结果的后处理阶段”:先从向量库里召回一批候选资料,再通过大模型把资料组织成自然语言回答。所以搜索扩展的核心其实是“检索质量”,而不是“生成质量”。如果向量检索阶段召回了一堆无关文档,后面的大模型再强也白搭。

2. 数据向量化:Embedding选型与文档切分的细节

2.1 Embedding模型怎么选

向量检索的第一步,是把商品文本转成向量。这一步的产出质量直接决定了整个RAG效果的上限。

我当时列了几个候选:

Embedding模型维度部署方式中文效果单条成本
OpenAI text-embedding-3-small1536/降维可用API调用良好按Token计费
BGE-M31024本地部署/API优秀,支持中文检索免费/CPU可跑
Ollama nomic-embed-text768本地部署一般,英文为主免费/本地

最后选了BGE-M3,原因有两个:一是本地部署不依赖外网,数据不出内网,二是中文效果确实能打。这里要提一个很多人忽略的细节:Embedding模型不是越贵越好,关键看三个点——是否支持中文(分词器对中文友好度)、向量维度是否和向量数据库的索引类型匹配、是否支持多语言混合检索。

Spring AI里Embedding模型都实现了一个统一的接口EmbeddingModel,所以后面想换成OpenAI的模型,只需要改配置,不用动业务代码:

@Service public class ProductEmbeddingService { private final EmbeddingModel embeddingModel; public ProductEmbeddingService(EmbeddingModel embeddingModel) { this.embeddingModel = embeddingModel; } public List<float[]> embed(List<String> texts) { return texts.stream() .map(text -> embeddingModel.embed(text)) .map(Embedding::getVector) .toList(); } }

2.2 文档切分:chunk size和overlap

实测下来,Embedding模型选完之后,真正决定检索质量的是“喂给模型的是什么”。商品详情动辄几千字,直接整段丢给Embedding模型,向量会被大量无关信息稀释,检索时根本找不到重点。

我当时用的切分策略:中文按字符数切,一个chunk控制在300~500个字符,overlap(重叠区间)设置80~100个字符。anchor点尽量落在自然段边界,避免把一个完整句子从中间切开。

为什么不直接用固定的token数?因为tokenizer对中文的分词结果不稳定,同样200个token有时是300个汉字,有时是450个汉字,反而让chunk大小不可控。

Spring AI里可以用DocumentSplitter相关工具,也可以直接写一个简单的切分器:

public List<Document> splitProductText(Product product) { String[] paragraphs = product.getDetail().split("\\n"); List<Document> docs = new ArrayList<>(); StringBuilder current = new StringBuilder(); int size = 0; for (String paragraph : paragraphs) { if (size + paragraph.length() > 400 && current.length() > 0) { docs.add(new Document("product:" + product.getId(), current.toString(), buildMetadata(product))); current = new StringBuilder(); size = 0; } current.append(paragraph).append("\n"); size += paragraph.length(); } if (current.length() > 0) { docs.add(new Document("product:" + product.getId(), current.toString(), buildMetadata(product))); } return docs; }

看到这里你可能会问:chunk是不是越小越好?不是。chunk太小,上下文信息不足,大模型拿到的片段缺少前后文,理解会出偏差;chunk太大,向量被稀释。300~500字符对大部分商品类文本来说是经验值,但你要针对自己的数据类型做几次实验,拿几十条真实Query去测召回效果再定。

2.3 metadata元数据:让检索结果可控

向量数据库检索的是“向量距离”,但业务场景里往往需要先过滤条件再算距离。比如只搜索“智能手表”品类下的商品,或者只搜索“2024年之后发布”的商品。这时候不能靠向量距离去约束,必须在向量入库时打好元数据标签。

我在每次写入Document时都会附带这样的metadata:

{ "productId": "SKU-10086", "category": "smartwatch", "brand": "某品牌", "price": 1999, "releaseYear": 2024 }

为什么一定要做这一步?举个反例:如果不加品类过滤,用户搜“运动手表”时,向量库可能召回一批“运动水壶”或“运动耳机”,因为它们在向量空间里离“运动”这个语境也很近。加了品类metadata之后,可以先用category == smartwatch缩小候选集,再做语义相似度计算,精度提升非常明显。

3. Spring AI接入向量数据库:从配置到落库

3.1 Spring AI的VectorStore抽象解决了什么问题

Spring AI把向量数据库的操作抽象成了一个VectorStore接口:写入用add,检索用similaritySearch。这个抽象的意义在于,它把“业务代码”和“具体向量库”解耦了。

举个例子:项目开发阶段我用Chroma,部署到测试环境换成Milvus,代码几乎不用动,改一下pom依赖和application.yml即可。这种可替换性对我们这种还在评估期的新项目很重要,因为我们前期根本不确定最后线上会用哪个向量库。

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

3.2 Milvus和Chroma的选择

我当时在实际项目中比较过两个向量库:

维度ChromaMilvus
部署成本简单,嵌入式/单机Docker需要Docker Compose或K8s
数据规模适合百万级以下的演示/小项目十亿级向量,分布式扩展
过滤能力基础metadata过滤丰富标量过滤+分区
持久化本地文件/内存独立存储,支持更多索引类型
适合场景本地开发、快速验证生产环境、大规模数据

我的建议很直接:如果只是本地跑通RAG Demo,直接用Chroma,省去一堆运维成本;如果准备上线,Milvus是更稳妥的选择。Milvus里的Collection就像一个数据库表,Spring AI只负责向它写入向量和做检索,索引创建、shard配置这类事情建议一开始就学一下,否则后面数据量上来只能干瞪眼。

3.3 写入向量数据的完整代码

以Milvus为例,Spring AI配置很简洁:

spring: ai: vectorstore: milvus: client: host: 127.0.0.1 port: 19530 database-name: product_db collection-name: product_collection index-type: HNSW metric-type: COSINE embedding-dimension: 1024

embedding-dimension必须和你用的Embedding模型输出维度一致,我当时BGE-M3是1024维,如果配置成768,写入时就会报错。

写入向量数据的完整方法:

@Service public class ProductVectorIndexService { private final VectorStore vectorStore; private final ProductEmbeddingService embeddingService; public void indexProduct(Product product) { // 1. 切分 List<Document> chunks = splitProductText(product); // 2. 写入向量库(Spring AI内部会自动调用EmbeddingModel对content做向量化) vectorStore.add(chunks); // 3. 更新索引状态,避免重复写入 product.setIndexed(true); } private Map<String, Object> buildMetadata(Product product) { return Map.of( "productId", product.getId(), "category", product.getCategory(), "price", product.getPrice(), "releaseYear", product.getReleaseYear() ); } }

这里有个容易忽略的点:很多人以为vectorStore.add()需要手动调用Embedding模型生成向量,其实Spring AI的VectorStore实现内部会自动调用配置好的EmbeddingModel来给Document里的文本生成向量。你只要保证注入的EmbeddingModel是BGE-M3对应的实现就行。

4. 构建RAG检索链路:从用户Query到生成回答

4.1 RAG链路全流程

完整的RAG检索链路,我用文字描述一遍:

  1. 用户输入问题,比如“适合夜跑、重量轻、有GPS的手表”
  2. 对用户Query调用Embedding模型,生成Query向量
  3. 用Query向量在向量数据库中做相似度检索,得到TopK个候选文档
  4. 把候选文档文本按固定格式拼接到Prompt上下文中
  5. 调用大模型(ChatModel),让它基于上下文回答问题

这五步里,第3步是核心。向量数据库返回的候选文档需不需要全部塞给大模型?不要。通常只取TopK(比如3~5条),太多会稀释大模型的注意力,响应时间也会变长。

4.2 通过QuestionAnswerAdvisor实现RAG问答

Spring AI里实现RAG最简洁的方式,是用内置的QuestionAnswerAdvisor

@Configuration public class RagConfig { @Bean public ChatClient chatClient(ChatModel chatModel, VectorStore vectorStore) { return ChatClient.builder(chatModel) .defaultAdvisors(Advisors.QuestionAnswerAdvisor(vectorStore)) .build(); } }

之后调用时,只要在Prompt里带上用户问题即可:

@Service public class ProductSearchService { private final ChatClient chatClient; public String search(String query) { return chatClient.prompt() .user(query) .call() .content(); } }

QuestionAnswerAdvisor内部会执行向量检索,并把结果自动放进Prompt。但这里有个坑:默认的QuestionAnswerAdvisor可能不会把所有你想要的metadata过滤条件暴露出来,比如你想限定“只能从智能手表品类里检索”,就得自定义请求:

SearchRequest request = SearchRequest.builder() .query("适合夜跑、重量轻、有GPS的手表") .topK(5) .similarityThreshold(0.65) .filterExpression("category == 'smartwatch'") .build();

然后在Advisor构造时传入SearchRequest。用filterExpression做的是先过滤再检索,比事后大模型过滤效果要好得多。

4.3 直接调similaritySearch:只做检索的用法

有些场景不需要大模型生成回答,比如FAQ自动推荐、相关商品展示、文档命中提示。这种时候我建议绕过ChatClient,直接用VectorStore检索:

public List<Document> retrieve(SearchRequest request) { return vectorStore.similaritySearch(request); }

这个方法的好处是延迟低、结果可控。大模型生成动辄1~3秒,而向量检索本身通常在几十毫秒内完成。在商品页做“相似商品推荐”时,我只用向量检索,完全不调大模型,线上成本和延迟压力都降了很多。

当时我做了个对比测试:单纯向量检索的P95延迟是180ms,而接大模型生成的P95延迟是2.8秒,差距是15倍。所以RAG链路也要想清楚,不是每个环节都值得花钱花时间去调用大模型。

5. 实测效果与调参:TopK、相似度阈值和召回质量

5.1 加不加RAG,效果差异有多大

上线之前,我先用30条从客服对话里抽出来的真实Query做了一组对比测试,分为三组:

  1. 原有关键词搜索(LIKE)
  2. 纯向量检索(相似度TopK)
  3. 完整RAG链路(向量检索+大模型生成)

测试结果简单粗暴:

方案结果命中率(人工判断)平均响应时间
关键词搜索37%50ms
纯向量检索72%160ms
RAG问答78%2.8s

向量检索直接比关键词搜索的命中率翻了一倍,而RAG相对纯向量检索的提升只有6个百分点。这6个百分点主要来自“将碎片化信息整合成完整回答”,而不是检索质量本身。也就是说,如果你的业务只是“召回相关商品列表”,纯向量检索就够了;如果要求“给出解释性的答案”,才需要上RAG。

5.2 相似度阈值和TopK怎么配

相似度阈值是向量检索里最容易拍脑袋的参数。阈值设低了,召回一堆无关结果;设高了,很多该召回的结果被过滤掉。

我当时用BGE-M3模型跑商品类数据,做了个简单的网格测试:

阈值TopK=3TopK=5TopK=10
0.550.210.620.62
0.650.220.660.61
0.750.200.400.35

这里的“数值”是被人工标记为“相关”的Query占比。测试样本有限,但趋势很明确:TopK=5优于TopK=3和TopK=10,阈值在0.65附近时综合效果最好。TopK=10会引入大量低质量长尾内容,而TopK=3又会漏掉“多路信息都沾一点边”的复杂Query。

所以最终线上配置是topK=5, similarityThreshold=0.65。但有一点必须强调:不同Embedding模型、不同数据域的阈值差异很大,别直接抄我的参数。建议做一个脚本,批量跑几十条测试Query,画一条“阈值-Precision/Recall”曲线再决定。

5.3 检索效果不佳时的排查顺序

如果向量检索效果不好,不要一上来就调阈值,按照下面顺序排查:

  1. 文档内容是否是检索友好的结构—— 如果原文本身信息密度低、废话多、重复度高,向量化后的质量必然是差的。先检查切分后的Document文本是否有清晰语义。
  2. Embedding模型是否匹配你的数据语言/领域—— 中文数据用了以英文为主的模型,效果基本是灾难。
  3. metadata过滤条件是否误伤—— 检查是否有字段类型不匹配导致过滤失效。
  4. 最后才调TopK和相似度阈值—— 这两个是“修剪”参数,检索质量不好时调它们只能掩盖问题,不能解决问题。

我遇到过一个很典型的案例:有一类商品的详情描述全是“参数规格表1. 2. 3.”这种序号列表,切分后每个chunk只有参数没有商品名,向量检索时因为缺少“上下文”导致召回率骤降。后来在切分前给每个chunk强制添加了商品名称前缀,效果明显改善。

6. 上线前必须处理的几个坑

6.1 Milvus连接超时与Collection自动创建

Spring AI接入Milvus后,第一次运行如果发现Collection一直创建不成功,多半不是你配置错了,而是Milvus服务本身的连接地址、端口或者数据库名有问题。我遇到过两种情况:

一是Milvus服务与本地应用不在同一个网络,但配置了127.0.0.1,这会导致连接拒绝。二是Milvus 2.x默认开启了数据库名,如果database-name配置的库不存在,Spring AI会自动尝试创建,但权限不够时创建失败。

还有一次排查了很久的Issue:应用启动报connection closed,查了半天发现是Milvus端health check超时设置太短,在数据量大、向量维度高的时候,首次创建Collection加上HNSW索引构建时间较长,超过了客户端默认超时时间。解决办法是调大Milvus客户端的connectTimeout参数。

6.2 中文文档的Embedding对齐

Chroma和Milvus存储向量时都不会区分Embedding模型,但你在检索时如果启用了多个Embedding模型,或者中途切换过模型,就会出现“数据空间不一致”的问题。

严格来说,一旦一个Collection里已经写入了A模型的向量,就不能再用B模型生成的Query向量去检索,因为两者落在完全不同的向量空间里。我当时在开发环境犯过这个错:一开始用Ollama的nomic-embed-text建了Collection,后来切到BGE-M3时没有清空Collection,检索结果简直是“天上一脚地上一脚”。所以切换Embedding模型时,一定要重建Collection或者按模型版本在metadata里打标。

6.3 metadata类型导致的过滤失效

这是最隐蔽的坑。Spring AI的Documentmetadata是Map<String, Object>,写入向量库后,不同vector store对类型支持不一致。比如Milvus对数值类型有严格限制,如果你往metadata里放了Map.of("price", 1999.0),但Collection schema里的price字段定义为INT64,写入时可能报错,过滤时可能匹配不上。

我踩过的具体问题是:把releaseYear写成字符串"2024",filterExpression写releaseYear == 2024,结果一条都过滤不出来。排查半天发现在写入时类型已经变成了字符串,数字比较永远为false。解决办法是在构建Document的metadata时,严格用与Collection schema一致的类型,写入前先打印一遍metadata确认。

还有一个容易被忽略的点:metadata的输出JSON大小会影响向量存储的吃盘量。3万条商品,每条metadata 200字节,算下来也有6MB,看似不多,但加上向量本身和索引后,Milvus的内存占用会比想象中快,最好只保留与业务过滤强相关的字段,别一股脑把整个查询日志塞进去。


这次改造做完以后,我的一个体会是:搜索扩展真正花时间的地方不在“接入Spring AI”,而在数据准备和检索质量的反复验证。向量数据库和RAG的组件都已经是现成的,难的是理解每个参数对结果的实际影响,然后用工程方法把效果量化出来。如果你也在做类似的事情,建议从最小的数据集开始,先把一条完整的向量检索链路跑通,再去扩展数据规模和RAG能力,不然很容易在一个不成熟的阶段同时被一堆问题淹没。下篇我会接着聊Embedding模型的微调、混合检索和重排,以及Agentic RAG的应用空间,到时候再分享更多实测数据。

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

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

立即咨询