
上个月接到一个需求要把官网的商品搜索升级成AI搜索用户问“能跑马拉松的防水运动手表”系统不能只返回名字里带“跑步”的商品还得理解“防水”“长续航”“轻量”这些隐含条件。当时第一反应是上ES但看了一眼索引里的数据形态和团队维护成本还是决定用Spring AI把向量数据库和RAG接进来。这篇就聊聊这次改造的完整思路重点在数据向量化、向量检索、RAG链路和调参经验适合已经在用Spring Boot、想给项目加语义搜索能力的朋友我尽量把每一步为什么要这么做讲清楚。先说结论RAG本身不是什么高深技术核心就是把“用户问题”和“已有资料”都转成向量在向量数据库里做相似度检索再把检索结果作为上下文丢给大模型去组织答案。但真正落地时难点全在细节里——Embedding模型选哪个、文档怎么切、相似度阈值设多少、检索结果怎么拼进Prompt任何一环偷懒效果都会垮。1. 先把问题拆清楚LIKE查询和ES为什么解决不了语义搜索1.1 一个真实的搜索需求变化过程我们当时的商品库大概有3万多条SKU每条记录里有名称、品类、属性JSON、详情描述。老的搜索就是一条SQLSELECT * 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 Listfloat[] embed(ListString 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 ListDocument splitProductText(Product product) { String[] paragraphs product.getDetail().split(\\n); ListDocument 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 groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-vector-store-milvus/artifactId /dependency3.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: 1024embedding-dimension必须和你用的Embedding模型输出维度一致我当时BGE-M3是1024维如果配置成768写入时就会报错。写入向量数据的完整方法Service public class ProductVectorIndexService { private final VectorStore vectorStore; private final ProductEmbeddingService embeddingService; public void indexProduct(Product product) { // 1. 切分 ListDocument chunks splitProductText(product); // 2. 写入向量库Spring AI内部会自动调用EmbeddingModel对content做向量化 vectorStore.add(chunks); // 3. 更新索引状态避免重复写入 product.setIndexed(true); } private MapString, 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检索链路我用文字描述一遍用户输入问题比如“适合夜跑、重量轻、有GPS的手表”对用户Query调用Embedding模型生成Query向量用Query向量在向量数据库中做相似度检索得到TopK个候选文档把候选文档文本按固定格式拼接到Prompt上下文中调用大模型ChatModel让它基于上下文回答问题这五步里第3步是核心。向量数据库返回的候选文档需不需要全部塞给大模型不要。通常只取TopK比如3~5条太多会稀释大模型的注意力响应时间也会变长。4.2 通过QuestionAnswerAdvisor实现RAG问答Spring AI里实现RAG最简洁的方式是用内置的QuestionAnswerAdvisorConfiguration 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 ListDocument retrieve(SearchRequest request) { return vectorStore.similaritySearch(request); }这个方法的好处是延迟低、结果可控。大模型生成动辄1~3秒而向量检索本身通常在几十毫秒内完成。在商品页做“相似商品推荐”时我只用向量检索完全不调大模型线上成本和延迟压力都降了很多。当时我做了个对比测试单纯向量检索的P95延迟是180ms而接大模型生成的P95延迟是2.8秒差距是15倍。所以RAG链路也要想清楚不是每个环节都值得花钱花时间去调用大模型。5. 实测效果与调参TopK、相似度阈值和召回质量5.1 加不加RAG效果差异有多大上线之前我先用30条从客服对话里抽出来的真实Query做了一组对比测试分为三组原有关键词搜索LIKE纯向量检索相似度TopK完整RAG链路向量检索大模型生成测试结果简单粗暴方案结果命中率人工判断平均响应时间关键词搜索37%50ms纯向量检索72%160msRAG问答78%2.8s向量检索直接比关键词搜索的命中率翻了一倍而RAG相对纯向量检索的提升只有6个百分点。这6个百分点主要来自“将碎片化信息整合成完整回答”而不是检索质量本身。也就是说如果你的业务只是“召回相关商品列表”纯向量检索就够了如果要求“给出解释性的答案”才需要上RAG。5.2 相似度阈值和TopK怎么配相似度阈值是向量检索里最容易拍脑袋的参数。阈值设低了召回一堆无关结果设高了很多该召回的结果被过滤掉。我当时用BGE-M3模型跑商品类数据做了个简单的网格测试阈值TopK3TopK5TopK100.550.210.620.620.650.220.660.610.750.200.400.35这里的“数值”是被人工标记为“相关”的Query占比。测试样本有限但趋势很明确TopK5优于TopK3和TopK10阈值在0.65附近时综合效果最好。TopK10会引入大量低质量长尾内容而TopK3又会漏掉“多路信息都沾一点边”的复杂Query。所以最终线上配置是topK5, similarityThreshold0.65。但有一点必须强调不同Embedding模型、不同数据域的阈值差异很大别直接抄我的参数。建议做一个脚本批量跑几十条测试Query画一条“阈值-Precision/Recall”曲线再决定。5.3 检索效果不佳时的排查顺序如果向量检索效果不好不要一上来就调阈值按照下面顺序排查文档内容是否是检索友好的结构—— 如果原文本身信息密度低、废话多、重复度高向量化后的质量必然是差的。先检查切分后的Document文本是否有清晰语义。Embedding模型是否匹配你的数据语言/领域—— 中文数据用了以英文为主的模型效果基本是灾难。metadata过滤条件是否误伤—— 检查是否有字段类型不匹配导致过滤失效。最后才调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是MapString, Object写入向量库后不同vector store对类型支持不一致。比如Milvus对数值类型有严格限制如果你往metadata里放了Map.of(price, 1999.0)但Collection schema里的price字段定义为INT64写入时可能报错过滤时可能匹配不上。我踩过的具体问题是把releaseYear写成字符串2024filterExpression写releaseYear 2024结果一条都过滤不出来。排查半天发现在写入时类型已经变成了字符串数字比较永远为false。解决办法是在构建Document的metadata时严格用与Collection schema一致的类型写入前先打印一遍metadata确认。还有一个容易被忽略的点metadata的输出JSON大小会影响向量存储的吃盘量。3万条商品每条metadata 200字节算下来也有6MB看似不多但加上向量本身和索引后Milvus的内存占用会比想象中快最好只保留与业务过滤强相关的字段别一股脑把整个查询日志塞进去。这次改造做完以后我的一个体会是搜索扩展真正花时间的地方不在“接入Spring AI”而在数据准备和检索质量的反复验证。向量数据库和RAG的组件都已经是现成的难的是理解每个参数对结果的实际影响然后用工程方法把效果量化出来。如果你也在做类似的事情建议从最小的数据集开始先把一条完整的向量检索链路跑通再去扩展数据规模和RAG能力不然很容易在一个不成熟的阶段同时被一堆问题淹没。下篇我会接着聊Embedding模型的微调、混合检索和重排以及Agentic RAG的应用空间到时候再分享更多实测数据。