做“Spring with AI ()”这个系列初衷很简单很多团队技术栈已经稳定在Spring Boot上现在又想把AI能力接进来但不想为了一个“智能搜索”就引入一整套新语言新框架。这篇文章对应系列里的“搜索扩展”主题核心是把向量数据库和RAG的底层逻辑讲清楚同时给一套能在Spring Boot工程里直接跑的Java实现。标题里的“(上)”也不是随便写的我打算先用这一篇把“向量化检索”这条链路打通后面再聊“把检索出来的片段拼进Prompt、让大模型基于私有知识生成答案”的完整RAG闭环。如果你正在做知识库问答、企业资料语义检索或是已经被“用户搜了关键词但文档里根本没有这个字”折磨过这篇应该对你有用。我把话先说在前面向量检索不是银弹RAG也不是把文档扔进去就能得到一个好用问答机器人。搜索这块的升级真正难的不是调用一个API而是理解“为什么从倒排索引换成了向量的最近邻”以及“怎么让检索单元、向量模型、相似度阈值互相配合”。这决定了你后面做出来的功能到底是玩具还是能上生产的东西。1. 先弄清楚搜索到底要扩展什么1.1 关键词搜索的边界在哪传统搜索的实现逻辑最容易理解的是倒排索引把文档拆成词建立一个“词 - 文档”的映射表。用户输入查询词系统去索引里找哪些文档包含这个词再按词频、位置、权重之类的因素排序。这种模式下搜索系统本质上是在做“字面匹配”。字面匹配稳绝大多数场景也没问题但它有一个先天性缺陷用户想的词和文档里写的词往往对不上。举个例子用户搜“苹果手机电池鼓包要不要换”你的知识库文档标题写得是“iPhone电池膨胀处理建议”这就出现了严重的主语不一致。传统搜索要做扩展常见办法是做同义词表、拼音纠错、相关搜索词推荐但这些规则要靠人慢慢维护覆盖面有限而且只能解决“已知的同义关系”。真正深层的问题在于语言表达是无限多样的“同一个意思”可以用几十种完全不同表面的句子来描述靠有限规则很难穷举。这也是为什么“搜索扩展”这件事后来发展出了更自动化的路线。与其让机器去硬凑字面不如让机器理解这句话背后的语义再拿着查询语义去匹配文档语义。1.2 向量检索的直觉理解向量检索解决这个问题的思路是用深度学习模型把一句话或一段文档映射成一组浮点数也就是Embedding向量。假设这段向量是128维你可以把它理解成这句话在一个“语义空间”里的坐标。同一个意思的表达不管用词差多远经过模型映射之后它们在这个空间里的位置会比较接近。可以把向量空间想象成一张地图所有文档都是地图上的点用户输入的查询也被映射成另一个点。向量数据库要做的不是遍历所有点算距离那样性能太差而是用一种叫近似最近邻的算法快速找到离查询点最近的一批文档点。这些“离得近”的文档在语义上大概率也是和查询相关的。比较两个向量相似度最常用的是余弦相似度计算公式形式上是这样cos(A, B) (A·B) / (|A| × |B|)这个值落在-1到1之间数值越大说明两个向量方向越一致语义越接近。做RAG检索时我们通常使用阈值在0到1之间来判断检索相关性取决于向量库的具体距离算法。这里有一个很容易误伤的细节不同向量模型的分数分布差异可能很大有的模型算出来的相似度普遍偏高有的偏低所以0.7这个阈值不是万能标准需要基于实际数据去调。1.3 RAG为什么需要一个检索步骤大模型虽然知识面广但它对你公司内部的制度文档、产品手册、售后话术并不了解。RAG即检索增强生成它的思路是分两步先检索出和问题相关的内容片段再把这些片段作为上下文和用户问题一起交给大模型让它基于给定材料来回答。这里的核心矛盾在于大模型能接受的输入Token是有限的你不能把几十万字的文档全部塞进Prompt也无法保证模型能从海量资料中准确找到那一小段关键信息。检索这一步的价值就是把“最可能跟当前问题相关”的内容片段从大资料库中挑出来。很多RAG项目效果不佳问题并不出在大模型本身而是出在检索这一步——根本没把相关片段捞出来或者捞出来的片段是残缺的、噪音很多的。所以我在这个系列里特意把检索部分作为单独一篇来写值得多花时间。2. 项目整体设计与选型思路2.1 离线索引与在线查询两条链路大多数RAG项目可以拆成两条链路。一条是离线索引链路把原始文档读进来做清洗、切分、向量化最后写入向量数据库。另一条是在线查询链路用户输入问题系统把问题向量化在向量数据库中检索相似片段再把结果返回给上层应用。这个设计在企业项目里有实际意义离线索引往往比较耗时一次要处理几百个文档向量化也非常吃接口配额或GPU资源所以应该做成后台任务而不是每次用户搜索都重新处理一遍原始文档。在线查询链路则要求低延迟通常在几百毫秒内返回但这里的耗时大头往往不是向量数据库检索而是向量化模型调用和后续大模型生成。如果你发现查询很慢先看清楚慢在哪一段再优化。2.2 向量数据库怎么选在Spring AI里向量存储被抽象成了一个叫VectorStore的接口底层实现可以切换成不同的数据库。这种抽象带来的好处是代码的业务逻辑不需要跟着底层数据库变选型时也不用把路堵死。我用一张表整理一下实际选型时容易遇到的场景向量存储方案适合场景注意点SimpleVectorStore本地跑通流程、单元测试、演示Demo数据存在内存JVM重启就没了Chroma小规模知识库、快速原型、想保留数据持久化部署简单但大规模并发能力一般Redis / Elasticsearch向量能力原有基础设施已经有Redis或ES不用额外引入系统但要评估版本能力PostgreSQL pgvector业务数据本身在PostgreSQL里可以和事务数据放一起但索引构建要关注Milvus / Qdrant海量数据、高并发生产环境需要独立部署运维成本更高对我来说判断标准很朴素如果只是做技术验证用SimpleVectorStore完全没问题如果你已经确定要长期在项目里用向量检索但团队不想维护额外数据库PostgreSQL的pgvector或者已有Redis的向量模块挺合适。如果数据量到了千万级或者查询QPS很高这时候再认真评估独立的专业向量数据库。这次示例我选择用Chroma来演示原因很简单它是独立数据库里部署成本最低的之一基本可以当本地服务跑而且Spring AI对它的集成比较成熟。Chroma里的Collection概念可以粗略理解成关系数据库的“表”它内部实际做的事情是把向量数据、文档原文、元数据一起存下来统一管理。2.3 Embedding模型决定语义质量上限向量检索的上限不是向量数据库决定的而是Embedding模型决定的。你自己可以做个实验同一个文档用两个不同Embedding模型生成向量检索结果可能差异很明显。汉语里这种例子尤其普遍比如“苹果”到底是水果还是手机品牌模型训练语料不同对上下文的捕捉能力也会有差别。我在这类项目里已经踩过几次模型选型的坑总结出的经验是如果业务语料以中文为主优先选择在中文语料上训练较好的模型比如bge系列或者业务偏垂直场景的话自己用领域数据微调Embedding模型。向量维度会影响存储开销和检索性能。OpenAI的text-embedding-3-small输出1536维很多开源模型是1024维或768维。维度越高理论上表达能力越强但存储和计算成本也在涨没必要盲目追高。向量化要统一用同一个模型。索引时用A模型查询时换了B模型生成的向量空间都不一样检索结果会完全失去意义。这个问题在团队协作时尤其容易发生。3. 用Spring AI写一个最小可运行的向量检索3.1 Spring Boot工程与依赖我当前示例基于Spring Boot 3.4.x和Spring AI 1.0.0版本。因为Spring AI的版本更新确实比较快并且很多接口在正式版前后有调整我这里会使用正式版的写法和大家演示如果看文章时版本已经又迭代了文中提到的关键接口仍然可以作为参照。先建立一个Spring Boot工程Java版本至少选17然后加入以下依赖dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-chroma-store/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency因为Spring AI已经提供了基于Spring Boot自动配置的Starter只要在application.yml中配置好API Key和向量库连接信息Spring容器里会自动出现EmbeddingModel和对应的VectorStore实现Bean省去手动new一堆对象的麻烦。如果是要跑通流程但不想在本地开Chroma服务可以暂时不用引入spring-ai-chroma-store直接用Spring AI提供的内存向量存储SimpleVectorStore。我自己在开发阶段经常是这样先把RAG流程的功能逻辑验证好再切到真实向量库。3.2 配置Embedding模型与Chroma连接Chroma需要先启动一个服务。最简单的跑法是用Docker容器本地起一个映射到8000端口的实例。Spring AI连接Chroma后可以自动生成所需的Collection。spring: ai: openai: base-url: ${OPENAI_BASE_URL:https://api.openai.com} api-key: ${OPENAI_API_KEY:} embedding: options: model: text-embedding-3-small vectorstore: chroma: client: host: http://localhost:8000 initialize-schema: true collection-name: knowledge_chunk关于collection-name这个配置我要多说一句它相当于给这批向量数据起了一个逻辑命名空间后续查询和写入都指向这个Collection。想重建索引时通常不是直接删除Chroma里的数据目录而是在代码或配置里换一个新的collection-name这样最安全可以避免老数据还没清干净就写新数据造成混淆。3.3 把文档切分成合适的检索片段在索引流程里最容易被忽视的是文档切分。很多人拿到一个PDF直接把整页文字塞给模型向量化然后入库结果检索质量很差。原因并不神秘当你把一篇5000字的文档压缩成一个向量时Embedding模型只能提取整体的主题无法保留每个局部细节的精确位置。检索时用户关心的是那5000字里某一个具体问题你和整篇文档的距离自然不会近。合理的做法是把文档切分成若干个较小的片段再分别向量化。切分参数通常涉及两个chunkSize片段长度和overlap相邻片段重叠长度。片段太长语义被稀释片段太短单个片段信息量不足回答问题时缺少上下文。我给一个适合起步的经验值中文文档大约切分500到800个Token重叠50到100个Token。这不是绝对的要根据文档类型调整。Spring AI里提供了一些现成的文本切分器比如TokenTextSplitter就是基于Token数量来切比按字符数切更接近模型实际看到的长度TextSplitter textSplitter new TokenTextSplitter(600, 100);如果把文档切得太碎还有一个隐患检索出来的一两个片段可能没头没尾需要把相邻的前后片段也一起返回才能给大模型充足的上下文。这种情况下重叠参数的设置就能起到一定效果至少能减少核心内容恰好被拦腰截断的几率。3.4 解析文档并写入向量库先用一个DocumentIndexService做索引写入。实际项目中原始数据可能来自数据库、PDF、Word、网页但这并不影响向量库写入层的设计。我们只需要把文本内容取出来加上必要的元数据构造出Document对象交给VectorStore。Service public class DocumentIndexService { private static final Logger log LoggerFactory.getLogger(DocumentIndexService.class); private final VectorStore vectorStore; private final TextSplitter textSplitter new TokenTextSplitter(600, 100); public DocumentIndexService(VectorStore vectorStore) { this.vectorStore vectorStore; } public int indexPlainText(String content, String source) { Document doc Document.builder() .text(content) .metadata(source, source) .build(); ListDocument chunks textSplitter.split(doc); vectorStore.add(chunks); log.info(索引完成文档来源{}切分片段数{}, source, chunks.size()); return chunks.size(); } }这里有一个经验元数据一定要提前想清楚。source表示来源是排查问题的重要线索强烈建议至少把文档名、页面号或业务主键放进metadata里。将来召回一个片段如果你都不知道它来自哪份文件那这条检索结果对用户来说也没有多大意义。3.5 查询与召回相似片段查询侧的核心是把用户的文本查询转换为向量然后在Collection中执行近邻搜索。Spring AI的VectorStore接口已经封装了完整流程Service public class VectorSearchService { private final VectorStore vectorStore; public VectorSearchService(VectorStore vectorStore) { this.vectorStore vectorStore; } public ListDocument search(String query, int topK, double similarityThreshold) { SearchRequest request SearchRequest.builder() .query(query) .topK(topK) .similarityThreshold(similarityThreshold) .build(); return vectorStore.similaritySearch(request); } }similarityThreshold的作用是过滤掉相似度过低的结果topK则决定最多返回多少条。给新手一个比较稳妥的建议一开始可以把topK调大一点比如返回10条先肉眼看一下召回结果里到底有几条是相关的然后再根据情况收紧。不要一上来就设个很低的阈值看见结果里全是无关内容会很打击信心。3.6 最小RAG闭环检索结果拼接上下文既然标题提到了RAG这里我先把最小闭环演示完用户提问 - 向量检索 - 把命中文档内容拼接成上下文 - 调用大模型生成回答。后续整个RAG问答的骨架基本都是这样构建的。Service public class SimpleRagService { private final VectorSearchService searchService; private final ChatClient chatClient; public SimpleRagService(VectorSearchService searchService, ChatClient.Builder chatClientBuilder) { this.searchService searchService; this.chatClient chatClientBuilder.build(); } public String answer(String question) { ListDocument docs searchService.search(question, 5, 0.5); String context docs.stream() .map(Document::getText) .collect(Collectors.joining(\n---\n)); return chatClient.prompt() .system(你是一个知识库问答助手。请优先根据用户提供的资料回答问题。 如果资料中找不到答案请明确说明资料中没有相关内容。) .user(参考资料如下\n context \n问题 question) .call() .content(); } }这个简单版本能跑通但后续可以优化的点很多比如只把检索结果的正文拼接起来容易超Token限制比如检索到相似但完全不相关的片段也会混进上下文。要在大规模应用里做好RAG还得在检索后增加重排序、过滤等步骤。这就留到系列后面的部分继续展开了。4. 检索结果怎么看相似度阈值和质量评估4.1 相似度分数只是参考排序才是关键很多人第一次跑通向量检索会盯着返回的score发呆到底多少分算相关我的回答是不要过度解读绝对分数先看排序。向量检索召回的分数其实是一个归一化后的相似度值。不同模型、不同向量数据库的分数分布差别很大。如果你拿一个0.7的阈值去过滤一个习惯于打出0.8、0.9分的模型可能把不少有效结果都过滤掉了但如果换成另外一个打分偏保守的模型0.7可能又过滤得太少。所以更好的做法是先看量化排序再结合具体场景设定阈值。Spring AI官方建议是用类似similarityThreshold的参数控制兜底而不是作为严格的分类器。我一般会把阈值设得偏低一些比如0.5左右然后用topK限制数量最后在代码里做进一步过滤或重排。4.2 通过元数据过滤来提升准确度向量相似度只是“宽泛相关”如果你的文档来源多样用户可能只想在特定范围内搜索。比如公司有产品手册、内部制度、售后记录几类文档用户问“请假流程”时如果内部制度文档里恰好包含“请假”二字可能就被召回了。最好在查询时加上元数据过滤条件。Spring AI的SearchRequest支持传入过滤表达式。例如只想在来源为internal_policy.md的文档里搜索SearchRequest request SearchRequest.builder() .query(question) .topK(5) .filterExpression(source internal_policy.md) .build();加了过滤后相当于在关系数据库里先加了WHERE条件再做向量检索能够有效减少无关干扰。元数据设计越合理后面能做的细粒度控制越多。4.3 为什么需要关注“混合检索”纯向量检索是稠密检索它擅长语义泛化但有一个明显不足对精确词、产品型号、专有名词不够敏感。用户搜“AW-320型号说明书”的时候如果Embedding模型没有把“AW-320”这个型号和文档中文案的关联学得足够好检索效果可能不如直接关键词搜索。所以在真实生产系统里越来越多人会做混合检索同时执行关键词检索通常称为稀疏检索和向量检索再把两者结果做融合排序。这正好呼应了标题里的“搜索扩展”——不是用向量搜索完全替代传统搜索而是在传统关键词搜索的基础之上增加一个语义维度。混合检索后续我会在系列里具体展开这里想表达的是向量检索和关键词检索不是对立关系它们是互补关系。5. 我在接入过程中踩过的坑5.1 Chroma连接和Schema自动初始化本地用Chroma时第一次启动后如果Spring应用连接不上十有八九是网络或配置问题。得先在浏览器访问http://localhost:8000/api/v1看一下服务是否正常返回。如果你用Docker启动要注意把端口映射出来并且保证Spring配置里的host端口指向一致。initialize-schema: true这个配置Spring AI会自动去Chroma里创建Collection。但如果Collection已经存在并且内部数据结构和预期不一致自动初始化不会帮你清空数据。所以真正要清空重建时要么在Chroma管理界面/API中删除Collection要么整体换一个新的Collection名称。5.2 元数据类型不一致导致写入失败Chroma对元数据的类型要求相对严格。我第一次往同一个Collection里写入时第一条文档的source字段存的是字符串后面又有代码意外把这个字段写成了数字结果Chroma直接报了类型不一致的错误整个批次写入失败。这个问题的排查看似很小一旦遇到还蛮折磨人的。建议在代码里把元数据的类型固定好并封装成统一的方法来构建Document不要把随意拼出来的Map直接塞进Document里。团队的规范尤其重要。5.3 片段切分导致语义残缺之前有一位同事问为什么他做出来的RAG效果很差。我查了一下他把一份文档按每200个字符切成一个片段而且没有重叠。结果一个完整的“申请流程说明”中间被硬生生切开了。用户问后半段流程时模型拿到的是没有前因后果的半截文字自然答不出来。这个经历让我总结出一个经验切分策略一定要结合文档本身的结构。如果文档有章节标题可以优先按标题或段落切分如果文档没有结构再退而用固定长度切分固定长度切分一定要设置重叠避免语义被截断。代码层面其实容易做但需要根据业务语料来做配置而不是默认所有文档都一套参数。5.4 Spring AI不同版本间的接口变化Spring AI还很年轻版本迭代带来的不兼容问题比一般Spring项目要多。网上各种教程有很多是基于0.8.x或1.0.0-M系列写的早期代码里的similaritySearch(String query, int topK)这种写法在后面的版本中已经慢慢被SearchRequest方式取代。如果你看到老教程的接口编译不过先别急着怀疑自己写错先去看当前版本里VectorStore接口到底定义了什么方法。要稳定构建最好直接以当前Spring AI文档和源码为准。这也是为什么我不喜欢把大量版本相关代码写死在一篇文里因为过几个月再读可能已经过时。但整体思想是稳定的先有EmbeddingModel再通过VectorStore写入和查询。5.5 向量化接口的超时和限流批量索引文档时很容易忽略一个实际问题向量化接口对并发请求有限制。我之前一次性索引500个文档片段直接并发发起了几百个Embedding请求结果被供应商接口限流大量的请求超时。这个问题在代码上解决比较直接给批量向量化加一个简单的信号量控制并发数比如限制同时只有8个请求。如果做生产级的索引任务还要考虑失败重试、进度记录、断点续传。很多人觉得RAG落地难难题往往不在算法而在这种工程化的小事上处理不好就是线上事故。最后分享一点我的调试习惯我记得第一次做RAG项目时总喜欢一出问题就怀疑大模型Prompt写得不对花大量时间调Prompt结果调了半天还是答不准。后来才发现根本不是Prompt的问题而是向量检索召回的内容压根就是错的或无关的你怎么提示大模型都没用巧妇难为无米之炊。我现在排查这类功能的固定顺序一直是三条线先验证Embedding模型能不能正常输出再单独调试VectorStore查询看召回结果最后才去检查Prompt和前端的完整链路。如果查询阶段就没召回正确内容那大概率要查文档切分策略、向量模型选择或者相似度阈值如果召回没问题但回答不对再回头查上下文拼装和Prompt约束。用这个顺序排查让我避免过很多次“白调Prompt”的无效工作。这一篇主要是把向量检索和RAG的底座讲清楚了。索引怎么写、Collection怎么配、检索怎么调、阈值怎么选这些问题解决后你的Spring Boot工程已经拥有了基本的“语义搜索”能力。下一篇文章我再继续聊怎样在这种检索能力的上面构建真正稳定可靠的RAG问答包括上下文压缩、重排序、多轮对话记忆以及怎么把召回结果做得更准。到时候见。