Spring AI框架中,向量数据库与RAG如何实现高效搜索扩展?

摘要:本文代码: https:github.comJunTeamComai-demotreerelease-4.0 https:github.comJunTeamComai-demo-toolstreerelease-4.
本文代码: https://github.com/JunTeamCom/ai-demo/tree/release-4.0 https://github.com/JunTeamCom/ai-demo-tools/tree/release-4.0/data-loader 本章只讲解RAG整体流程、向量数据库、数据写入; 查询向量数据库、RAG应用在下一讲。 Spring with AI系列,只关注上层AI的应用程序(基于JAVA搭建),不关注底层的LLM原理、搭建等技术。 RAG能通过实时搜索数据库的方式,扩展已经训练固化的大模型的知识能力。 RAG(Retrieval-Augmented Generation)检索增强生成 retrieval /rɪˈtriːvl/ n.检索 augmented /ɔ:g'mentɪd/ adj.增强的 generation /ˌdʒenəˈreɪʃn/ n.生成 通过检索、增强大模型生成的内容。 在前文中,我们通过文本模板,补充了“规则”,进而增强了大模型生成的内容。 然而这只是简单的“文本匹配+配置文件”的方式,面对复杂问题和庞大的知识库,完全不具备现实意义。 RAG整体的流程和组成部分如下: flowchart LR subgraph VectorDB[向量数据库模块] VectorStore[(向量数据库)]:::db end subgraph DocumentLoader[文档加载模块] OriginalDocs[① 原始文档] --> Splitter[文档分割器]:::process Splitter --> DocChunks[② 文档分块] DocChunks --> CalcEmb[③ 计算向量嵌入]:::process CalcEmb -->|存入| VectorStore end subgraph RAGApp[支持RAG的应用] Question[问题] --> App[④ 应用]:::app App -->|搜索相似文档| VectorStore VectorStore -->|返回相似文档| App App --> Prompt[⑤ 组装提示词] Prompt --> LLM[大语言模型] LLM --> Answer[答案] Answer --> App App --> Output[输出答案] end classDef process fill:#a5d6a7,stroke:#388e3c,stroke-width:2px classDef app fill:#ffab91,stroke:#e64a19,stroke-width:2px classDef db fill:#bbdefb,stroke:#1976d2,stroke-width:2px classDef llm fill:#ffcc80,stroke:#f57c00,stroke-width:2px 1 引言 自然语言,是怎么变成向量、并且用于检索的呢? 将句子拆分为最小语义单元(token),再通过词汇表为每个token分配唯一ID;这样自然语言就编码变成了向量。例如:["我", "爱", "北京"] → [3, 54, 65]。再进行编码、降维等处理,自然语言就变成了向量。 如何检索呢?本质上是计算问题与答案直接的相关性,也就是“距离”;而这个距离,是通过向量的“余弦相似度”测算的,计算两个向量夹角的余弦值来衡量它们相似度的一种方法,值越接近1表示越相似,越接近-1表示越不相似。这种算法的优点是: 不受向量长度影响:只关注方向一致性,适合不同长度的文本或特征向量。 计算复杂度低:尤其适合稀疏向量,只需考虑非零分量。 2 安装向量数据库 本文以Qdrant(功能全部署相对简单)为例。当然还有Milvus(阿里云向量数据库基础)、ChromaDB(Python原生支持)、FAISS(Meta开源产品)等选择,没有本质的差别。 比较完整的一个向量数据库列表: Apache Cassandra Chroma Elasticsearch GemFire SAP Hana Milvus MongoDB Neo4j Pinecone PostgreSQL with pgvector extension Qdrant Redis with RediSearch module Weaviate FAISS 可以看到,一些列存储数据库/KV数据库/文档数据库/图数据库也在列表。 Qdrant安装还是相对复杂的,所以一般是推荐开箱即用的Docker方式;不过考虑到性能、还有存储目录等因素,本文才用了原生安装方式: https://github.com/qdrant/qdrant/releases Windows一般选择qdrant-x86_64-pc-windows-msvc.zip版本;比如解压到E:\Softwares\Qdrant mkdir E:\Softwares\Qdrant 然后创建几个子目录: mkdir E:\Softwares\Qdrant\storage mkdir E:\Softwares\Qdrant\config mkdir E:\Softwares\Qdrant\static notepad E:\Softwares\Qdrant\config\config.yaml 然后编辑配置文件: service: host: 0.0.0.0 http_port: 6333 grpc_port: 6334 storage: storage_path: "./storage" 然后下载Web客户端: https://github.com/qdrant/qdrant-web-ui/releases 将包内的文件,解压到static文件夹: Directory: E:\Softwares\Qdrant\static Mode LastWriteTime Length Name ---- ------------- ------ ---- d---- 2026/2/19 17:35 assets -a--- 2026/2/19 17:35 15086 favicon.ico -a--- 2026/2/19 17:35 1790 index.html -a--- 2026/2/19 17:35 6371 logo-red-black.svg -a--- 2026/2/19 17:35 6359 logo-red-white.svg -a--- 2026/2/19 17:35 9339 logo.png -a--- 2026/2/19 17:35 6177 logo192.png -a--- 2026/2/19 17:35 23528 logo512.png -a--- 2026/2/19 17:35 484 manifest.json -a--- 2026/2/19 17:35 436518 openapi.json -a--- 2026/2/19 17:35 834374 qdrant-web-ui.spdx.json -a--- 2026/2/19 17:35 67 robots.txt 启动Qdrant: cd E:\Softwares\Qdrant\ \.Qdrant.exe 日志里显示: Version: 1.17.0, build: 4ab6d2ee Access web UI at http://localhost:6333/dashboard 打开链接即可。 在Tutorial - Quick Start菜单,在第一页一路Run、即可创建一个Collection(类似MongoDB的Collection,可以认为是个表;这一步可以不做,只是为了理解向量数据库) 然后在右上角🔑菜单点入,设置API Key;然后环境变量添加QDRANT_API_KEY;然后重启IDE 3 JAVA工程添加配置 需要新建一个data-loader的JAVA工程,实现数据导入;这样方便权限隔离。 本文为了简单化,后续所有工具都放到一个Git项目里。 先建一个Github项目: JAVA工程除了使用Starter,也可以使用IDE生成SpringBoot基础项目、再手动添加依赖(如上图所示,以VSCode为例): starter(通过Add Spring Boot Starters添加) spring-boot-starter-web(通过Web搜索) spring-ai-starter-model-openai(通过OpenAI搜索) spring-ai-starter-vector-store-qdrant(通过Qdrant搜索) spring-ai-tika-document-reader(通过Tika搜索) spring-cloud-function-context(通过Function搜索) lombok(通过Lombok搜索) dependency(通过Add a dependency from Maven Central Repository添加) spring-file-supplier(通过File Supplier搜索) spring-functions-catalog-bom(通过Functions Catalog搜索) 或者可以建一个基础的SPring Boot项目,然后添加starter和dependency 在JAVA工程里配置Qdrant链接、并配置OpenAI API: spring: application: name: data-loader ai: openai: base-url: https://dashscope.aliyuncs.com/compatible-mode # Qwen的OpenAI式Endpoint api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen3.5-plus embedding: # 新增嵌入模型配置 options: model: text-embedding-v2 # 阿里云支持的嵌入模型 vectorstore: qdrant: host: 127.0.0.1 port: 6334 api-key: ${QDRANT_API_KEY} initialize-schema: true collection-name: ai_demo server: port: 0 4 数据导入 除了用Web管理后台导入、或者手动调用QdrantAPI,生产环境一般用JAVA调用QdrantAPI导入数据。 4.1 定义工作流 基于Spring Function Catalog创建工作流: spring: cloud: function: definition: > fileSupplier| documentReader| splitter| titleDeterminer| vectorStoreConsumer file: supplier: directory: /var/dropoff filename-regex: .*\.(pdf|docx|txt) 注意的是,Windows中/var/dropoff指向的是JAVA工程所在盘符的对应目录,例如: E:/var/dropoff 上面工作流的步骤说明: fileSupplier步骤的实现,直接由配置项file.supplier提供(参考Spring Functions Catalog相关文档) 后续步骤的实现,需要定义相关的@Bean;为了简化代码,全部定义在Startup类 还需要定义一个启动@Bean 代码如下: package com.junteam.ai.dataloader; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.function.Consumer; import java.util.function.Function; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.document.Document; import org.springframework.ai.reader.tika.TikaDocumentReader; import org.springframework.ai.transformer.splitter.TokenTextSplitter; import org.springframework.ai.vectorstore.VectorStore; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.ApplicationRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.function.context.FunctionCatalog; import org.springframework.context.annotation.Bean; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; import reactor.core.publisher.Flux; import reactor.core.scheduler.Schedulers; @SpringBootApplication public class DataLoaderApplication { private static final Logger log = LoggerFactory.getLogger(DataLoaderApplication.class); public static void main(String[] args) { SpringApplication.run(DataLoaderApplication.class, args); } @Bean Function<Flux<byte[]>, Flux<Document>> documentReader() { return resourceFlux -> resourceFlux .map(fileBytes -> new TikaDocumentReader( new ByteArrayResource(fileBytes)) .get() .getFirst()) .subscribeOn(Schedulers.boundedElastic()); } @Bean Function<Flux<Document>, Flux<List<Document>>> splitter() { var splitter = new TokenTextSplitter(); return documentFlux -> documentFlux .map(incoming -> splitter .apply(List.of(incoming))) .subscribeOn(Schedulers.boundedElastic()); } @Value("classpath:/promptTemplates/nameOfTheCountry.st") Resource nameOfTheCountryTemplateResource; @Bean Function<Flux<List<Document>>, Flux<List<Document>>> titleDeterminer(ChatClient.Builder chatClientBuilder) { var chatClient = chatClientBuilder.build(); return documentListFlux -> documentListFlux .map(documents -> { if (!documents.isEmpty()) { var firstDocument = documents.getFirst(); var countryTitle = chatClient.prompt() .user(userSpec -> userSpec .text(nameOfTheCountryTemplateResource) .param("document", firstDocument.getText())) .call() .entity(CountryTitle.class); if (Objects.requireNonNull(countryTitle).title().equals("未知")) { log.warn("Unable to determine the name of a country; " + "not adding to vector store."); documents = Collections.emptyList(); return documents; } log.info("Determined country title to be {}", countryTitle.title()); documents = documents.stream().peek(document -> { document.getMetadata() .put("countryTitle", countryTitle.title()); }).toList(); } return documents; }); } @Bean Consumer<Flux<List<Document>>> vectorStoreConsumer(VectorStore vectorStore) { return documentFlux -> documentFlux .doOnNext(documents -> { if (!documents.isEmpty()) { var docCount = documents.size(); log.info("Writing {} documents to vector store.", docCount); vectorStore.accept(documents); log.info( "{} documents have been written to vector store.", docCount); } }) .subscribe(); } @Bean ApplicationRunner go(FunctionCatalog catalog) { Runnable composedFunction = catalog.lookup(null); return args -> { composedFunction.run(); }; } } 其中本次的提示词模板: 你的任务是根据文档(在“文档:”后面)中给出的规则来确定国家名称。 该文档将是国家说明的简短摘录。文档中可能会明确说明国家名称,也可能不会。 如果未明确说明国家名称,请将国家名称设置为“未知”。 文档: {document} 4.2 补充内容 这样具体场景的流程如下: flowchart LR subgraph VectorDB[向量数据库模块] VectorStore[(🗄️ 向量数据库<br>支持按国家/语义检索)]:::db end subgraph DataLoader[数据加载与预处理模块] direction TB OriginalDocs[📚 原始文档<br>国家历史地理风俗] --> EntityExtract[🏷️ 大模型自动抽取<br>国家名称 & 描述信息] EntityExtract --> Splitter[✂️ 文档分割器] Splitter --> DocChunks[📄 文档分块<br>含元数据: 国家/地区] DocChunks --> CalcEmb[🧮 计算向量嵌入] CalcEmb -->|存入向量与元数据| VectorStore end subgraph RAGApp[智能问答应用] direction TB Question[❓ 用户问题<br>例: 日本有哪些传统节日?] --> App[④ 应用入口]:::app App -->|1. 问题分析与意图识别| IntentCheck{是否涉及<br>已知国家/地区?} IntentCheck -->|是| SearchByCountry[🔍 按国家元数据过滤] IntentCheck -->|否| SemanticSearch[🔍 纯语义相似搜索] SearchByCountry -->|组合查询| VectorStore SemanticSearch --> VectorStore VectorStore -->|返回相似文档块<br>+ 所属国家信息| App App --> Prompt[⑤ 组装增强提示词<br>包含: 检索到的风俗/地理描述<br>+ 原始问题] Prompt --> LLM[🤖 大语言模型]:::llm LLM --> Answer[✨ 增强答案<br>例: 依据日本文化资料,主要节日有...] Answer --> App App --> Output[📢 输出答案] end classDef process fill:#a5d6a7,stroke:#388e3c,stroke-width:2px classDef app fill:#ffab91,stroke:#e64a19,stroke-width:2px classDef db fill:#bbdefb,stroke:#1976d2,stroke-width:2px classDef llm fill:#ffcc80,stroke:#f57c00,stroke-width:2px 可以看到: RAG,通过搜索能力、增强大模型生成内容的质量。 核心的一个误解,就是搜索能力不是在大模型生成完之后补充结果、也不是把搜索内容导入到大模型内部。 而是组装到发给大模型的提示词里。 5 启动与验证 5.1 启动 启动data-loader项目,然后在/var/dropoff文件夹里放入一些资料: document-德国.txt: 德国各地有华人网站,比如德累斯顿华人网。 德国各地有华人微信群。 德国网购一般使用易贝、亚马逊平台。 德国买东西一般去超市。 德国生活用品购买常常去IDEL。 德国化妆品护肤品一般去ROSSMAN、DM购买。 德国购物节是黑色星期五。 document-意大利.txt: 意大利各地常常有特色的工艺品,比如威尼斯有面具,玻璃岛有玻璃制品,米兰的黄金首饰。 意大利罗马,市容卫生比较差,随地乱扔垃圾,而且小偷比较多。 5.2 验证 运行shell脚本(可以使用GitBash运行): curl http://localhost:6333/collections 返回内容如下: { "result":{ "collections":[{"name":"ai_demo"},{"name":"star_charts"}]}, "status":"ok", "time":5.5e-6 } 这说明数据集(可以看作表)已经自动创建。 运行shell脚本(可以使用GitBash运行): curl -X POST http://localhost:6333/collections/ai_demo/points/count \ -H "Content-Type: application/json" \ -d '{"exact": true}' 返回内容如下: {"result":{"count":2},"status":"ok","time":0.0004876} 这说明数据已经成功导入。