Tika + Milvus + Spring AI 搭建企业级 RAG 知识库(解析→切块→向量化→检索→问答一条龙可落地)

前三篇我们把「Excel 精细化处理」「全格式文档解析」逐个解决了:

今天,我们把这几个工具 Spring AI + Milvus + Tika 三剑客串起来,搭一套真正可在企业落地的 RAG(Retrieval-Augmented Generation,检索增强生成)知识库。之后,你公司里 PDF、Word、Excel、PPT、Markdown、扫描件 OCR…… 上传后往知识库一丢,员工就可以用自然语言提问,拿到**”基于公司内部文档 + 带引用 + 不幻觉”** 的精准回答。

和网上玩具级 Demo 不一样,本篇会直面生产环境的 7 大硬问题:

  1. ✅ 1000+ 种格式文档解析(Tika 自带);
  2. ✅ 智能切块(TokenTextSplitter + 按语义边界,不切断半句话);
  3. ✅ 向量数据库选型与索引(Milvus 2.6.x,HNSW + COSINE);
  4. ✅ Spring AI 统一 ChatClient + Embedding 抽象(换模型不改代码);
  5. ✅ 多租户/按权限过滤(SQL-like metadata filter);
  6. ✅ 回答自带来源引用(员工能点回去看原文);
  7. ✅ 异步文档入库 + 进度可查(大文件不阻塞接口)。

全部代码复制即可跑通,结尾附带一张”RAG 效果调优路线图”,帮你从 60 分做到 90 分。


0)一张图看清整条链路

先上架构全景,后面每一章按箭头从上到下实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
┌───────────────────────────────────────────────────────────────────────────┐
│ 【离线/异步】文档入库管道 │
│ │
│ 上传文件(PDF/Word/Excel/PPT/图片) │
│ ↓ │
│ TikaDocumentReader (Tika) ──▶ 提取 纯文本 + 元数据(source/mime/页数) │
│ ↓ │
│ TokenTextSplitter ──▶ 按 Token 智能切块(chunk 500 + overlap 50) │
│ ↓ │
│ EmbeddingModel(OpenAI/国产) ──▶ 每个 chunk → 1536 维向量 │
│ ↓ │
│ Milvus VectorStore ──▶ collection=kb_docs, 存向量+文本+元数据 │
└───────────────────────────────────────────────────────────────────────────┘

┌───────────────────────────────────────────────────────────────────────────┐
│ 【在线/同步】问答检索管道 │
│ │
│ 用户问题:"2026年Q3差旅报销标准是什么?" │
│ ↓ │
│ EmbeddingModel ──▶ 问题 → 1536 维向量 │
│ ↓ │
│ Milvus 相似度检索 ──▶ Top-K=6 最相似 chunk + 按租户过滤 metadata │
│ ↓ │
│ QuestionAnswerAdvisor ──▶ 拼 System Prompt: 上下文 + "只基于以下回答" 指令 │
│ ↓ │
│ ChatClient (LLM) ──▶ 生成最终答案 + 列出引用来源 │
│ ↓ │
│ 返回前端: answer + sources[] (文件名/页码/原文片段) │
└───────────────────────────────────────────────────────────────────────────┘

0.1 技术栈版本(截至 2026 Q3,生产推荐组合)

组件选型版本说明
基础运行Spring Boot3.3.5JDK 17+,稳定主流
AI 框架Spring AI1.1.7官方推荐,支持 OpenAI/通义/文心/Ollama 统一抽象
文档解析Apache Tika2.9.4通过 spring-ai-tika-document-reader 引入
向量数据库Milvus2.6.24 LTS千万级向量首选;如果规模 < 100w 可降级为 PGVector
Embedding 模型OpenAI text-embedding-3-small / 通义 text-embedding-v31536 维中文场景推荐 bge-m3 或通义 embedding
Chat 模型GPT-4o-mini / 通义千问 4-plus / 文心 4.0-回答生成,可随时换

1)基础设施:Milvus + Spring Boot 项目骨架

1.1 一键起 Milvus(开发环境,Docker Compose Standalone)

Milvus 官方强烈推荐的开发部署方式:三个容器(Milvus 节点 + etcd 元数据 + MinIO 对象存储),一启动就好,生产再换分布式集群。

新建 infra/docker-compose.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
version: '3.8'
services:
etcd:
container_name: milvus-etcd
image: quay.io/coreos/etcd:v3.5.16
environment:
- ETCD_AUTO_COMPACTION_MODE=revision
- ETCD_AUTO_COMPACTION_RETENTION=1000
- ETCD_QUOTA_BACKEND_BYTES=4294967296
- ETCD_SNAPSHOT_COUNT=50000
volumes:
- ./volumes/etcd:/etcd-data
command: etcd -data-dir=/etcd-data --listen-client-urls=http://0.0.0.0:2379 --advertise-client-urls=http://etcd:2379

minio:
container_name: milvus-minio
image: minio/minio:RELEASE.2026-09-19T19-27-05Z
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
volumes:
- ./volumes/minio:/minio_data
ports:
- "9000:9000"
- "9001:9001"
command: minio server /minio_data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3

standalone:
container_name: milvus-standalone
image: milvusdb/milvus:v2.6.24
command: ["milvus", "run", "standalone"]
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
volumes:
- ./volumes/milvus:/var/lib/milvus
ports:
- "19530:19530" # gRPC:Java SDK 用这个
- "9091:9091" # HTTP:Web UI / Attu 用这个
depends_on:
- "etcd"
- "minio"

启动 + 验证:

1
2
3
4
5
6
7
8
cd infra
docker compose up -d

# 三个容器都 healthy 就 OK
docker compose ps

# 打开 Web UI:http://localhost:9091 (默认 root/Milvus,也可以不填直接登录)
# 或者安装 Attu 客户端(Milvus 官方桌面可视化工具)

⚠️ 资源提醒:Milvus Standalone 三个容器约占 4~6GB 内存,开发机至少 8GB;16GB 才舒服。

1.2 完整 pom.xml 依赖

Spring AI 现在正式发布在 Maven Central,不再需要 milestone 仓库,真省心~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
<relativePath/>
</parent>

<groupId>com.example</groupId>
<artifactId>kb-rag</artifactId>
<version>1.0.0</version>

<properties>
<java.version>17</java.version>
<spring-ai.version>1.1.7</spring-ai.version>
<hutool.version>5.8.32</hutool.version>
</properties>

<!-- 关键:Spring AI BOM 版本管理,所有 starter 不用写版本号 -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<!-- ===== Web ===== -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<!-- ===== Spring AI 核心 + OpenAI 全家桶 (Chat + Embedding) ===== -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>

<!-- ===== Tika 文档阅读器:一句话读 PDF/Word/Excel/PPT...1000+ 格式 ===== -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId>
</dependency>

<!-- ===== Milvus 向量数据库 Starter:Spring AI 自动配置 VectorStore ===== -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-milvus-store-spring-boot-starter</artifactId>
</dependency>

<!-- ===== 工具 & 日志 ===== -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

💡 换国产大模型?把 spring-ai-openai-spring-boot-starter 替换成:

  • 通义千问:spring-ai-dashscope-spring-boot-starter(spring.ai.dashscope.api-key);
  • 文心:spring-ai-qianfan-spring-boot-starter;
  • Ollama 本地模型:spring-ai-ollama-spring-boot-starter(零成本内网跑)。

下面代码一行都不用改,因为我们全部用 ChatClient / EmbeddingModel 抽象。

1.3 application.yml:配置串起来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
server:
port: 8080
servlet:
multipart:
max-file-size: 200MB
max-request-size: 200MB

spring:
application:
name: kb-rag

# ========== AI 模型:以 OpenAI 为例;其他模型改这里的 key 即可 ==========
ai:
openai:
api-key: ${OPENAI_API_KEY:sk-your-key}
# base-url: https://api.deepseek.com (或者任何 OpenAI 兼容中转)

# 问答模型
chat:
options:
model: gpt-4o-mini
temperature: 0.2 # 知识库问答要低温度,别让模型自由发挥
top-p: 0.9

# Embedding 模型(维度必须和 Milvus 配置一致!)
embedding:
options:
model: text-embedding-3-small # 1536 维,性价比最高
dimensions: 1536

# ========== Milvus 向量库配置 ==========
vectorstore:
milvus:
client:
host: localhost
port: 19530
username: root # 默认
password: Milvus # 默认,生产务必改密码
databaseName: default
collectionName: kb_docs # 集合名,相当于 SQL 的 table
dimension: 1536 # ⚠️ 必须和 embedding 模型输出维度完全一致!
indexType: HNSW # 最通用的 ANN 索引(HNSW/IVF_FLAT/DISKANN)
metricType: COSINE # 余弦相似度,Embedding 模型标配
initialize-schema: true # 自动建 collection,生产建议关
task:
execution:
pool:
thread-name-prefix: rag-
core-size: 4
max-size: 8
queue-capacity: 200

# 自定义
rag:
ingest:
chunk-size: 500 # 每块 token 数
chunk-overlap: 50 # 重叠 token(保留上下文连续)
retrieval:
top-k: 6 # 取最相似的前 6 块
similarity-threshold: 0.7 # 相似度低于 0.7 的扔掉,减少幻觉

2)Spring AI 核心 Bean 初始化(ChatClient + 文本切块器)

Spring Boot Starter 会自动为你配置 ChatModel、EmbeddingModel、VectorStore。我们只需要再加点业务友好的 Bean:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Configuration
public class RagAutoConfig {

@Bean
public ChatClient chatClient(ChatModel chatModel) {
// Spring AI 2.x 推荐的 Fluent API,类似 WebClient / RestClient
return ChatClient.builder(chatModel).build();
}

/**
* Token 切块器:比按字符切块准得多
* chunkSize = 500 token(约 350~700 个汉字)
* chunkOverlap = 50 token(相邻块保留重叠,防止语义断裂)
* keepSeparator = true 保留断句符号
*/
@Bean
public TokenTextSplitter tokenTextSplitter(
@Value("${rag.ingest.chunk-size:500}") int chunkSize,
@Value("${rag.ingest.chunk-overlap:50}") int chunkOverlap) {
return TokenTextSplitter.builder()
.withChunkSize(chunkSize)
.withChunkOverlap(chunkOverlap)
.withMinChunkSizeChars(60) // 小于 60 字的碎块丢弃
.withMinChunkLengthToEmbed(10)
.withMaxNumChunks(Integer.MAX_VALUE)
.withKeepSeparator(true)
.build();
}
}

3)核心模块一:文档入库服务(异步 + 进度可查)

上传的文件一般不小,接口同步等 2 分钟不可接受。和 Fesod 那篇的思路一致:立刻返回任务 ID → 后台 @Async 跑 → 前端轮询进度。

3.1 任务模型

1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
@Builder
public class IngestTaskInfo implements Serializable {
private String taskId;
private String fileName;
private IngestStatus status; // PENDING / RUNNING / SUCCESS / FAILED
private int totalChunks; // 总共多少块
private int embeddedChunks; // 已向量化多少块(进度百分比)
private String message;
private LocalDateTime createTime;

public enum IngestStatus { PENDING, RUNNING, SUCCESS, FAILED }
}

3.2 入库服务(ETL 三步骤:Load → Split → Embed & Store)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
@Slf4j
@Service
@RequiredArgsConstructor
public class KnowledgeIngestService {

private final VectorStore vectorStore;
private final TokenTextSplitter tokenTextSplitter;
private final IngestTaskService taskService; // 进度存 Redis/DB,见第 3.3 节

/**
* 提交入库任务 → 立刻返回 taskId
*
* @param file 上传文件
* @param tenantId 租户ID(多租户场景)
* @param docTags 文档标签(元数据,方便后面按 tag 过滤)
*/
public String submit(MultipartFile file, String tenantId, List<String> docTags) throws IOException {
// 1) 落盘临时文件(Tika 读 File 比读 byte[] 省内存,大文件很重要)
String taskId = IdUtil.fastSimpleUUID();
String safeName = file.getOriginalFilename() == null ? "file" : file.getOriginalFilename();
Path tmp = Files.createTempFile("kb-ingest-", "_" + safeName);
file.transferTo(tmp.toFile());

taskService.create(taskId, safeName);
doIngestAsync(taskId, tmp, safeName, tenantId, docTags); // @Async
return taskId;
}

@Async
@Transactional(propagation = Propagation.NOT_SUPPORTED) // 别让长任务持有事务
public void doIngestAsync(String taskId, Path filePath, String fileName,
String tenantId, List<String> docTags) {
try {
taskService.update(taskId, IngestStatus.RUNNING, 0, 0, "开始解析文档");

// ===== STEP 1: Tika 加载 + 提取文本 + 元数据 =====
TikaDocumentReader reader = new TikaDocumentReader(new FileSystemResource(filePath));
List<Document> docs = reader.get(); // 一般返回 1 个 Document(整份文件)
log.info("Tika 解析完成:{},doc 数={}, 总字符数={}",
fileName, docs.size(), docs.stream().mapToInt(d -> d.getText().length()).sum());

if (CollUtil.isEmpty(docs) || StrUtil.isAllBlank(docs.get(0).getText())) {
// 如果是扫描件 PDF / 图片,Tika 读不出文字 → 走 OCR(下面讲)
taskService.update(taskId, IngestStatus.FAILED, 0, 0,
"未能提取到文字内容;如为扫描件请先开启 OCR 预处理");
return;
}

// ===== STEP 2: 统一打元数据(切块后每块都会保留,检索时过滤用) =====
final Map<String, Object> commonMeta = buildCommonMeta(tenantId, docTags, fileName, filePath);
docs.forEach(doc -> doc.getMetadata().putAll(commonMeta));

// ===== STEP 3: 智能切块(TokenTextSplitter) =====
List<Document> chunks = tokenTextSplitter.apply(docs);
log.info("切块完成:{},共 {} 块", fileName, chunks.size());

taskService.update(taskId, IngestStatus.RUNNING, chunks.size(), 0, "切块完成,开始向量化");

// ===== STEP 4: 分批向量化 + 写入 Milvus(每 100 块一批,降低 Embedding API 并发压力) =====
List<List<Document>> batches = Lists.partition(chunks, 100);
for (int i = 0; i < batches.size(); i++) {
List<Document> batch = batches.get(i);
vectorStore.add(batch); // Spring AI 内部会调 EmbeddingModel → 存 Milvus
int done = Math.min((i + 1) * 100, chunks.size());
taskService.update(taskId, IngestStatus.RUNNING, chunks.size(), done,
"已向量化 " + done + "/" + chunks.size());
}

taskService.update(taskId, IngestStatus.SUCCESS, chunks.size(), chunks.size(),
"入库完成,共 " + chunks.size() + " 个知识块");
// 清理临时文件
FileUtils.deleteQuietly(filePath.toFile());

} catch (Exception e) {
log.error("入库失败 taskId={} file={}", taskId, fileName, e);
taskService.update(taskId, IngestStatus.FAILED, 0, 0, "入库失败: " + e.getMessage());
}
}

private Map<String, Object> buildCommonMeta(String tenantId, List<String> docTags,
String fileName, Path filePath) {
Map<String, Object> m = new HashMap<>();
m.put("source", fileName);
m.put("tenant_id", tenantId);
if (CollUtil.isNotEmpty(docTags)) m.put("tags", String.join(",", docTags));
// 这里还可以加:部门 department_id、文档类别 category、权限级别 security_level 等
try {
m.put("file_size_kb", Files.size(filePath) / 1024);
} catch (IOException ignore) {}
return m;
}
}

3.3 进度查询服务(最简单版本用 Caffeine 本地缓存;生产换 Redis)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Service
public class IngestTaskService {

// 简单用 ConcurrentHashMap;生产请改成 Redis 带 24h 过期
private final Map<String, IngestTaskInfo> cache = new ConcurrentHashMap<>();

public void create(String taskId, String fileName) {
cache.put(taskId, IngestTaskInfo.builder()
.taskId(taskId).fileName(fileName)
.status(IngestTaskInfo.IngestStatus.PENDING)
.createTime(LocalDateTime.now()).build());
}

public void update(String taskId, IngestTaskInfo.IngestStatus status,
int total, int done, String message) {
IngestTaskInfo info = cache.get(taskId);
if (info == null) return;
info.setStatus(status);
info.setTotalChunks(total);
info.setEmbeddedChunks(done);
info.setMessage(message);
}

public IngestTaskInfo get(String taskId) {
return cache.get(taskId);
}
}

3.4 扫描件 PDF / 图片:OCR 预处理(可选)

前面检测到 Tika 提取的文本为空时,判断是扫描件。我们直接复用 Apache Tika 教程实战四 中的 OCR 能力,在入库前先 OCR 一把转成可搜索文本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 在 KnowledgeIngestService 里注入 Tika Bean(第 3 篇封装过的那个)
private final Tika tika;
private final TesseractOCRConfig ocrConfig; // 只有开启 OCR 时存在

/**
* 备用:Tika + Tesseract 对扫描件做 OCR
*/
private String ocrExtract(Path file) throws IOException, TikaException {
BodyContentHandler handler = new BodyContentHandler(-1);
ParseContext ctx = new ParseContext();
if (ocrConfig != null) ctx.set(TesseractOCRConfig.class, ocrConfig);
try (InputStream in = TikaInputStream.get(file)) {
new AutoDetectParser().parse(in, handler, new Metadata(), ctx);
} catch (SAXException e) {
throw new RuntimeException(e);
}
return handler.toString();
}

生产建议:OCR 比较慢(1 页 PDF 1~3 秒),可以单独做个”OCR 异步队列”先把扫描件转成可搜索 PDF 再走入库流程。


4)核心模块二:问答检索服务(相似度检索 + Prompt 组装 + 来源引用)

这是在线问答的核心,也是最容易”幻觉”的地方。Spring AI 给了 QuestionAnswerAdvisor 帮你自动把”检索到的知识”塞进 Prompt,再传给 LLM 生成回答。下面我们一步步来。

4.1 知识库查询接口 Service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
@Slf4j
@Service
@RequiredArgsConstructor
public class KnowledgeQAService {

private final ChatClient chatClient;
private final VectorStore vectorStore;

@Value("${rag.retrieval.top-k:6}")
private int topK;
@Value("${rag.retrieval.similarity-threshold:0.7}")
private double similarityThreshold;

/* =======================================================
* 方式 A:原生手写(最灵活,自定义 Prompt / 来源提取)
* ======================================================= */
public QAResponse answer(String question, String tenantId, List<String> requireTags) {
// 1) 构造检索请求:Top-K + 相似度阈值 + 元数据过滤(按租户/tag/权限)
SearchRequest req = SearchRequest.builder()
.query(question)
.topK(topK)
.similarityThreshold(similarityThreshold)
// ====== 核心!SQL-like 元数据过滤器(Milvus 支持) ======
.filterExpression(buildFilter(tenantId, requireTags))
.build();

// 2) 走 Milvus 相似度检索
List<Document> docs = vectorStore.similaritySearch(req);
log.info("问题: [{}],检索到 {} 个相关块", question, docs.size());

if (CollUtil.isEmpty(docs)) {
return QAResponse.builder()
.answer("知识库中暂未找到相关资料,请换个问法或确认文档已入库。")
.needMoreInfo(true)
.sources(Collections.emptyList())
.build();
}

// 3) 拼 Prompt:要求模型"只根据上下文回答",并在末尾注入来源列表
String context = assembleContext(docs);
String answer = chatClient.prompt()
.system(s -> s.text(SYSTEM_PROMPT))
.user(u -> u.text(USER_PROMPT_TEMPLATE, question, context))
.call()
.content();

// 4) 把检索到的来源带出去给前端展示(点引用跳原文)
List<SourceRef> sources = docs.stream()
.map(d -> new SourceRef(
d.getMetadata().getOrDefault("source", "未知文件").toString(),
safeInt(d.getMetadata().get("chunk_index")),
d.getText().substring(0, Math.min(100, d.getText().length())),
safeDouble(d.getMetadata().get("distance"))
))
.distinct()
.toList();

return QAResponse.builder()
.answer(answer)
.needMoreInfo(false)
.sources(sources)
.build();
}

/* =======================================================
* 方式 B:Spring AI Advisor 自动装配(代码最少,1.1.x+ 推荐)
* ======================================================= */
public String answerWithAdvisor(String question) {
QuestionAnswerAdvisor advisor = QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder().query(question).topK(topK).build())
// 自定义系统提示模板
.systemPromptText(SYSTEM_PROMPT)
.build();
return chatClient.prompt()
.advisors(a -> a.advisors(advisor))
.user(question)
.call()
.content();
}

/* ---------------- helper ---------------- */

/**
* 多租户 / 标签 的 Milvus Filter 表达式(和 SQL 类似)
* Milvus 支持的运算符: == != > < >= <= && || like in []
*/
private String buildFilter(String tenantId, List<String> requireTags) {
StringBuilder expr = new StringBuilder("tenant_id == '" + tenantId + "'");
if (CollUtil.isNotEmpty(requireTags)) {
// tags 是逗号分隔字符串:只要包含任意一个命中 tag 就算
String ors = requireTags.stream()
.map(t -> "tags like '%" + t + "%'")
.collect(Collectors.joining(" || "));
expr.append(" && (").append(ors).append(")");
}
return expr.toString();
}

private String assembleContext(List<Document> docs) {
StringBuilder sb = new StringBuilder();
int idx = 0;
for (Document d : docs) {
idx++;
String file = d.getMetadata().getOrDefault("source", "文档").toString();
sb.append("--- [参考资料 ").append(idx).append(":").append(file).append("] ---\n")
.append(d.getText()).append("\n\n");
}
return sb.toString();
}

private static final String SYSTEM_PROMPT = """
你是企业内部的知识库助手,必须严格遵守以下规则:
1. 只能依据下面【参考资料】中的内容回答,严禁编造资料中不存在的信息;
2. 如果参考资料里没有答案,必须明确回答"知识库中没有找到相关内容",不要猜测;
3. 回答尽量分点、条理清晰,保留原文中的数字和条款;
4. 如果回答引用了某个具体的参考资料,在结尾标注 (参考资料 N)。
""";

private static final String USER_PROMPT_TEMPLATE = """
用户问题:
{0}

【参考资料】
{1}

请严格按照系统规则回答。
""";

private static int safeInt(Object o) {
if (o instanceof Number n) return n.intValue();
try { return Integer.parseInt(String.valueOf(o)); } catch (Exception ignore) { return 0; }
}
private static double safeDouble(Object o) {
if (o instanceof Number n) return n.doubleValue();
try { return Double.parseDouble(String.valueOf(o)); } catch (Exception ignore) { return 0d; }
}
}

4.2 返回 VO

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Data
@Builder
public class QAResponse {
/** 模型回答 */
private String answer;
/** 是否没找到答案(提示用户补充文档) */
private boolean needMoreInfo;
/** 引用来源列表,前端可以点击"查看原文" */
private List<SourceRef> sources;
}

@Data
@AllArgsConstructor
public class SourceRef {
private String fileName; // 来源文件名
private int chunkIndex; // 第几块
private String preview; // 前 100 字预览
private double distance; // 相似度(越小越相关)
}

5)Controller 接口:4 个端点跑通整个 RAG

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
@RestController
@RequestMapping("/api/kb")
@RequiredArgsConstructor
public class KnowledgeController {

private final KnowledgeIngestService ingestService;
private final IngestTaskService taskService;
private final KnowledgeQAService qaService;

/* ========== 1. 上传文档 → 提交入库 ========== */
@PostMapping("/ingest")
public R<String> ingest(@RequestParam("file") MultipartFile file,
@RequestParam String tenantId,
@RequestParam(required = false) List<String> tags) throws IOException {
if (file.isEmpty()) return R.fail("请选择文件");
String taskId = ingestService.submit(file, tenantId, tags);
return R.ok(taskId);
}

/* ========== 2. 查询入库进度 ========== */
@GetMapping("/ingest/{taskId}")
public R<IngestTaskInfo> ingestInfo(@PathVariable String taskId) {
IngestTaskInfo info = taskService.get(taskId);
if (info == null) return R.fail("任务不存在");
return R.ok(info);
}

/* ========== 3. 提问 ========== */
@PostMapping("/qa")
public R<QAResponse> qa(@RequestBody @Valid QAQuery q) {
QAResponse resp = qaService.answer(q.getQuestion(), q.getTenantId(), q.getTags());
return R.ok(resp);
}

/* ========== 4. 流式提问(SSE,可选,打字机效果) ========== */
@GetMapping(value = "/qa/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> qaStream(@RequestParam String question,
@RequestParam String tenantId) {
// 和 answer() 类似,先检索 → 再 stream() 输出
SearchRequest req = SearchRequest.builder()
.query(question).topK(6).similarityThreshold(0.7)
.filterExpression("tenant_id == '" + tenantId + "'")
.build();
List<Document> docs = vectorStore.similaritySearch(req);
String ctx = docs.isEmpty() ? "" : docs.stream()
.map(d -> "--- [" + d.getMetadata().get("source") + "] ---\n" + d.getText())
.collect(Collectors.joining("\n\n"));

return chatClient.prompt()
.system(s -> s.text("你是企业知识库助手,只根据参考资料回答。"))
.user(u -> u.text("问题:{0}\n参考资料:\n{1}", question, ctx))
.stream()
.content()
.onErrorReturn("\n[流中断,请重试]");
}
}

请求体:

1
2
3
4
5
6
@Data
public class QAQuery {
@NotBlank private String question;
@NotBlank private String tenantId;
private List<String> tags;
}

通用 R<T> 就是第二篇里的那个 {code,msg,data} 结构,直接复用。


6)生产级 7 个硬问题的解决思路

6.1 文档去重 & 增量更新

用户同一份文件反复上传怎么办?在 buildCommonMeta 时加 content_hash 字段(文件 SHA256),入库前:

1
2
3
4
5
6
7
8
9
String hash = SecureUtil.sha256(filePath.toFile());
SearchRequest dup = SearchRequest.builder()
.query("dummy").topK(1) // 不关心内容,只按 hash 过滤查 1 条
.filterExpression("content_hash == '" + hash + "'")
.build();
if (CollUtil.isNotEmpty(vectorStore.similaritySearch(dup))) {
taskService.update(taskId, SUCCESS, 0, 0, "文档已存在,跳过");
return;
}

6.2 删除 / 更新某份文档

因为每个 chunk 上都有 source(文件名)和 content_hash,按元数据删:

1
2
3
// Spring AI VectorStore 还没暴露统一 delete-by-filter,Milvus 可以直接用 SDK 或 API:
// 伪代码:milvusClient.delete("kb_docs", "source == '2026Q3报销制度.pdf'")
// 然后再走一次完整入库流程

6.3 中文 Embedding 效果差 → 换国产 Embedding + 更大 chunk

OpenAI text-embedding-3-small 对中文的理解不如国产中文 Embedding。推荐替换:

1
2
3
4
5
6
7
8
9
10
11
spring:
ai:
dashscope:
api-key: ${DASHSCOPE_KEY}
embedding:
options:
model: text-embedding-v3
dimensions: 1024
vectorstore:
milvus:
dimension: 1024 # 跟着模型维度改!

6.4 回答的引用不准 → 加 Rerank 重排序

相似度检索 Top-K=30 → Rerank 模型(bge-reranker、通义 Rerank)精排到 Top 6,再给 LLM。这一步通常能提升 15%~30% 准确率。

6.5 接口慢 / 成本高 → Embedding 缓存

同一个 chunk 的文本不会变,Embedding 结果可以本地 Caffeine 10 分钟缓存或 Redis 缓存,避免反复调 API:

1
2
3
4
5
6
7
8
9
10
11
@Bean
public EmbeddingModel cachingEmbedding(EmbeddingModel delegate) {
LoadingCache<String, float[]> cache = Caffeine.newBuilder()
.maximumSize(50_000)
.expireAfterWrite(Duration.ofDays(7))
.build(text -> delegate.embed(text));
return new EmbeddingModel() {
@Override public float[] embed(String text) { return cache.get(text); }
@Override public EmbeddingResponse call(EmbeddingRequest req) { return delegate.call(req); }
};
}

6.6 知识库回答”胡说八道” → 加 Self-Check

让模型在回答结尾输出 [[confidence:0.9]],低于 0.6 的直接打回”未找到”。或者让 LLM 自己二次检查:

1
2
3
二次检查 Prompt:
请检查上面的回答是否完全来自参考资料?回答 YES/NO + 说明。
如果 NO,就不要回答。

6.7 超大文档(> 1000 页 PDF)

  • 分层分块:Parent Document Retrieval —— 检索用小 chunk (200 token),拼 Prompt 用整个父 section (2000 token);
  • 摘要索引:先让 LLM 给每个 section 写 1 句摘要,检索时先匹配摘要,再展开原 section。

7)本地快速验证 & 调用示例

7.1 上传文档

1
2
3
4
5
curl -X POST http://localhost:8080/api/kb/ingest \
-F "file=@/Users/you/docs/2026Q3差旅报销制度.pdf" \
-F "tenantId=t001" \
-F "tags=报销制度"
# → {"code":0, "data":"c5f2e9...xx"} (返回 taskId)

轮询进度:

1
2
curl http://localhost:8080/api/kb/ingest/c5f2e9...xx
# → {status: SUCCESS, totalChunks: 24, embeddedChunks:24, message:"入库完成..."}

7.2 提问

1
2
3
curl -X POST http://localhost:8080/api/kb/qa \
-H "Content-Type: application/json" \
-d '{"question":"乘坐高铁一等座报销标准是多少?","tenantId":"t001","tags":["报销制度"]}'

预期返回:

1
2
3
4
5
6
7
8
9
10
{
"code": 0,
"data": {
"answer": "根据《2026Q3差旅报销制度》,高铁一等座报销标准如下:...(此处是 LLM 生成的回答) (参考资料 2)",
"needMoreInfo": false,
"sources": [
{"fileName":"2026Q3差旅报销制度.pdf","chunkIndex":12,"preview":"第六条 高铁与动车报销...","distance":0.123}
]
}
}

完成!🎉 从文档上传到问答全链路跑通。


8)总结 & RAG 从 60 分 → 90 分的成长路线图

8.1 本篇交付物

模块功能代码位置
基础设施Milvus Docker Compose 一键启动 + pom.xml + application.yml第 1 章
文档入库服务Tika → 切块 → Embedding → Milvus 异步 + 进度第 3 章
问答服务相似度检索 + 元数据过滤 + Prompt 组装 + 来源引用 + 流式第 4、5 章
生产加固去重、OCR、缓存、Self-Check、Rerank、大文档分层第 6 章

8.2 RAG 效果分级路线图

级别特征做法
60 分(可用)基础问答 + 来源 + 不瞎编本篇全部内容
70 分(准确)中文 Embedding + Rerank + 更大 Top-KCh6.3 + 6.4
80 分(好用)多轮对话记忆 + Query 改写 + 问答缓存MessageChatMemoryAdvisor + 重写用户问题
90 分(企业级)权限过滤(RBAC)+ Agent 工具调用 + 指标监控(召回率/准确率)元数据里加 dept_ids、security_level;接入 Micrometer Tracing
95+(极致)图 RAG / 混合检索(向量 + BM25 全文) + 知识图谱 + 评估集自动回归Milvus 2.6 支持 BM25 全文混合检索,效果飙升

8.3 与前面三篇的配合图

1
2
3
4
5
6
7
8
9
10
11
12
13
┌───────────────────────────────────────────────────────────────────────┐
│ 企业文档处理技术全景 │
│ │
│ 【Excel 精细化业务场景】 │
│ ① Fesod 入门 → 读 / 写 / 注解映射 │
│ ② Fesod Spring Boot 生产组件 → 异步任务 + 错误报告 + 模板填充 │
│ │
│ 【全格式内容提取】 │
│ ③ Tika 入门到生产 → 1000+ 格式 + 元数据 + MIME + OCR │
│ │
│ 【企业 RAG 知识库】(本篇) │
│ ④ Tika + Milvus + Spring AI → 解析→切块→向量→检索→问答一条龙 │
└───────────────────────────────────────────────────────────────────────┘

把这 4 篇代码整合进你的项目,Excel 导入导出、文档合规审计、智能知识库问答 三件套直接拉满,再也不用因为”解析格式”和”查不到资料”加班啦 🚀。


Tika + Milvus + Spring AI 搭建企业级 RAG 知识库(解析→切块→向量化→检索→问答一条龙可落地)
https://www.pcboy.com.cn/2026/09/30/Tika-Milvus-SpringAI企业级RAG知识库实战/
作者
chituer
发布于
2026年9月30日
许可协议