本文代码:
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(R etrieval-A ugmented G eneration)检索增强生成
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
powershell
mkdir E:\Softwares\Qdrant
然后创建几个子目录:
powershell
mkdir E:\Softwares\Qdrant\storage
mkdir E:\Softwares\Qdrant\config
mkdir E:\Softwares\Qdrant\static
notepad E:\Softwares\Qdrant\config\config.yaml
然后编辑配置文件:
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文件夹:
powershell
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:
powershell
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:
yaml
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创建工作流:
yaml
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
代码如下:
java
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运行):
bash
curl http://localhost:6333/collections
返回内容如下:
json
{
"result":{
"collections":[{"name":"ai_demo"},{"name":"star_charts"}]},
"status":"ok",
"time":5.5e-6
}
这说明数据集(可以看作表)已经自动创建。
运行shell脚本(可以使用GitBash运行):
bash
curl -X POST http://localhost:6333/collections/ai_demo/points/count \
-H "Content-Type: application/json" \
-d '{"exact": true}'
返回内容如下:
json
{"result":{"count":2},"status":"ok","time":0.0004876}
这说明数据已经成功导入。