Spring AI 实战系列 | 进阶篇 第 2 篇
企业知识库 RAG 管线:多格式 ETL + 混合检索
系列说明:本文为《Spring AI 实战系列 进阶篇》第 2 篇
前置知识:完成入门篇第 3 篇(VectorStore + RAG)和进阶篇第 1 篇(智能客服系统)
预计阅读时间:15 分钟
💻 开发环境说明
| 项目 | 配置 |
|---|---|
| 电脑型号 | MagicBook Pro 14 2025,14.6 吋 |
| CPU | Intel Core Ultra 5(MTL Ultra5) |
| 核显 | Intel UMA |
| 内存 | 24GB |
| 硬盘 | 1TB SSD |
| 大模型 | 智谱 GLM-4-Flash(云端 API) |
| 向量数据库 | Chroma(本地 Docker) |
📖 目录
一、入门篇 RAG 的局限性
入门篇第 3 篇我们实现了一个基础的文档问答系统,能跑通 RAG 的完整流程。但放到真实企业场景里,会遇到一堆问题:
1.1 真实场景的挑战
| 问题 | 入门篇的做法 | 企业场景的现实 |
|---|---|---|
| 文档格式 | 只支持 Markdown | 有 PDF、Word、Excel、HTML、代码文件 |
| 文档分块 | 固定 Token 数切割 | 切到一半把一段代码切断了,语义丢失 |
| 检索方式 | 纯向量相似度 | 搜"订单号 B-12345"这种精确词,向量检索完全找不到 |
| 关键词分词 | n-gram 分词 | 噪音大,"退货流程"被切成"退货"、"货流"、"流程" |
1.2 本篇要解决的问题
入门篇 RAG:
文档 → 固定分块 → 向量存储 → 向量检索 → AI 回答
进阶篇 RAG:
多格式文档 → 智能分块 → Chroma 向量库
↘
混合检索(向量 + jieba关键词)→ AI 回答
↗
jieba 关键词索引 ─────────────────────
二、企业级 RAG 管线全景
2.1 完整架构
┌─────────────────────────────────────────────────────────────────────┐
│ 企业知识库 RAG 管线 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 【摄入管线 - 离线/异步】 │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 文档来源 │ │
│ │ PDF ─┐ │ │
│ │ HTML─┤→ DocumentReader → 语义分块 → Embedding → 双路存储 │ │
│ │ TXT ─┘ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 双路存储 │ │
│ │ SimpleVectorStore(语义检索) + jieba 关键词索引(精确检索) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ 【查询管线 - 在线】 │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 用户问题 → 混合检索(RRF融合)→ AI 生成 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
2.2 核心组件
| 组件 | 职责 | 本文实现方式 |
|---|---|---|
| DocumentReader | 解析多格式文档 | Spring AI 内置 Reader |
| SemanticChunkSplitter | 按段落语义分块 | 自定义 Transformer |
| Chroma | 向量存储与语义检索 | 本地 Docker 部署 |
| KeywordIndex | 精确词匹配 | jieba 分词 + 内存索引 |
| HybridSearchService | 融合两路检索结果 | RRF 算法 |
三、多格式 ETL:文档摄入管线
3.1 Spring AI 内置的 DocumentReader
Spring AI 提供了开箱即用的多格式读取器:
// PDF 读取(按段落分割,语义更完整)
ParagraphPdfDocumentReader pdfReader = new ParagraphPdfDocumentReader(resource);
// Markdown 读取
MarkdownDocumentReader mdReader = new MarkdownDocumentReader(resource,
MarkdownDocumentReaderConfig.builder()
.withHorizontalRuleCreateDocument(true)
.withIncludeCodeBlock(true)
.build());
// 纯文本读取
TextReader textReader = new TextReader(resource);
3.2 多格式文档加载器(核心代码)
@Component
public class MultiFormatDocumentLoader {
public List<Document> load(Resource resource) {
String filename = resource.getFilename();
String ext = filename.toLowerCase();
if (ext.endsWith(".pdf")) {
return new ParagraphPdfDocumentReader(resource).read();
} else if (ext.endsWith(".md")) {
return new MarkdownDocumentReader(resource, config).read();
} else if (ext.endsWith(".txt") || ext.endsWith(".html")) {
return new TextReader(resource).read();
}
throw new UnsupportedOperationException("暂不支持的格式: " + ext);
}
}
3.3 元数据注入
@Component
public class MetadataEnricher implements DocumentTransformer {
@Override
public List<Document> apply(List<Document> documents) {
return documents.stream()
.map(doc -> {
Map<String, Object> meta = new HashMap<>(doc.getMetadata());
meta.put("ingest_time", LocalDateTime.now().toString());
meta.put("char_count", doc.getText().length());
return new Document(doc.getText(), meta);
})
.toList();
}
}
四、智能分块策略
4.1 为什么固定分块不够用?
原文:
"退货流程如下:
1. 联系客服提交申请
2. 客服审核(1-3个工作日)
3. 寄回商品"
固定 512 Token 切割后(在第 2 步中间切断):
片段1:"退货流程如下:1. 联系客服提交申请 2. 客服审核(1-3"
片段2:"个工作日)3. 寄回商品"
→ 两个片段都不完整,检索时语义丢失
4.2 语义分块器(按自然段落)
@Component
public class SemanticChunkSplitter implements DocumentTransformer {
private static final int MAX_CHUNK_SIZE = 800;
private static final int MIN_CHUNK_SIZE = 100;
private static final int OVERLAP_SIZE = 80;
@Override
public List<Document> apply(List<Document> documents) {
return documents.stream()
.flatMap(doc -> split(doc).stream())
.toList();
}
private List<Document> split(Document doc) {
String text = doc.getText();
if (text.length() <= MAX_CHUNK_SIZE) {
return List.of(doc);
}
List<Document> chunks = new ArrayList<>();
// 按自然段落分割(空行分隔)
String[] paragraphs = text.split("\n\n+");
StringBuilder currentChunk = new StringBuilder();
int chunkIndex = 0;
for (String paragraph : paragraphs) {
if (currentChunk.length() + paragraph.length() > MAX_CHUNK_SIZE
&& currentChunk.length() >= MIN_CHUNK_SIZE) {
// 保存当前块
chunks.add(createChunk(doc, currentChunk, chunkIndex++));
// 保留最后一段作为重叠
currentChunk = new StringBuilder();
if (paragraph.length() <= OVERLAP_SIZE) {
currentChunk.append(paragraph).append("\n\n");
}
}
currentChunk.append(paragraph).append("\n\n");
}
if (currentChunk.length() >= MIN_CHUNK_SIZE) {
chunks.add(createChunk(doc, currentChunk, chunkIndex));
}
return chunks;
}
}
4.3 分块策略选择指南
| 文档类型 | 推荐策略 | 原因 |
|---|---|---|
| 产品手册 / FAQ | 语义分块(按段落) | 每个问答是独立语义单元 |
| 技术文档 | Markdown Reader + 标题分割 | 标题天然是语义边界 |
| PDF 合同 | ParagraphPdfDocumentReader | 保留段落结构 |
| 新闻/文章 | TokenTextSplitter + overlap | 内容连续,需要重叠保留上下文 |
五、混合检索:语义 + 关键词
5.1 为什么需要混合检索?
用户问:"订单号 B-12345 的退货状态是什么?"
向量检索:
→ 找到"退货流程说明"、"退款政策"等语义相近的文档
→ 但"B-12345"这个精确订单号,向量检索找不到!
关键词检索:
→ 直接匹配"B-12345"
→ 精准命中包含该订单号的文档
5.2 混合检索实现(RRF 融合)
@Service
public class HybridSearchService {
private final VectorStore vectorStore;
private final KeywordIndex keywordIndex;
public List<Document> search(String query, int topK) {
// 1. 向量检索(语义相似)
List<Document> vectorResults = vectorStore.similaritySearch(
SearchRequest.builder()
.query(query)
.topK(topK * 2)
.similarityThreshold(0.5)
.build()
);
// 2. 关键词检索(精确匹配)
List<Document> keywordResults = keywordIndex.search(query, topK * 2);
// 3. RRF 融合排序
return reciprocalRankFusion(vectorResults, keywordResults, topK);
}
/**
* Reciprocal Rank Fusion(倒数排名融合)
* score(d) = Σ 1 / (k + rank_i(d)), k = 60
*/
private List<Document> reciprocalRankFusion(
List<Document> vectorResults,
List<Document> keywordResults,
int topK) {
final int K = 60;
Map<String, Double> scores = new HashMap<>();
Map<String, Document> docMap = new HashMap<>();
// 向量检索分数
for (int i = 0; i < vectorResults.size(); i++) {
String id = getDocId(vectorResults.get(i));
scores.merge(id, 1.0 / (K + i + 1), Double::sum);
docMap.put(id, vectorResults.get(i));
}
// 关键词检索分数(叠加)
for (int i = 0; i < keywordResults.size(); i++) {
String id = getDocId(keywordResults.get(i));
scores.merge(id, 1.0 / (K + i + 1), Double::sum);
docMap.put(id, keywordResults.get(i));
}
// 按分数降序排列
return scores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(topK)
.map(e -> docMap.get(e.getKey()))
.toList();
}
}
六、jieba 分词优化
6.1 为什么要用 jieba?
| 分词方案 | 问题 | 示例 |
|---|---|---|
| n-gram | 噪音大,无意义词组多 | "退货流程" → "退货"、"货流"、"流程" |
| jieba | 精准分词,语义完整 | "退货流程" → "退货"、"流程" ✅ |
6.2 添加 jieba 依赖
<!-- pom.xml -->
<dependency>
<groupId>com.huaban</groupId>
<artifactId>jieba-analysis</artifactId>
<version>1.0.2</version>
</dependency>
6.3 关键词索引(jieba 版)
@Component
public class KeywordIndex {
private JiebaSegmenter segmenter; // jieba 分词器
private final Map<String, List<Document>> invertedIndex = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
// 预加载词典(首次较慢)
this.segmenter = new JiebaSegmenter();
}
public void addDocuments(List<Document> documents) {
for (Document doc : documents) {
Set<String> keywords = extractKeywords(doc.getText());
for (String keyword : keywords) {
invertedIndex.computeIfAbsent(keyword, k -> new CopyOnWriteArrayList<>())
.add(doc);
}
}
}
public List<Document> search(String query, int topK) {
Set<String> queryKeywords = extractKeywords(query);
// 统计每个文档的关键词命中数
Map<Document, Integer> hitCount = new HashMap<>();
for (String keyword : queryKeywords) {
for (Document doc : invertedIndex.getOrDefault(keyword, List.of())) {
hitCount.merge(doc, 1, Integer::sum);
}
}
// 按命中数降序排列
return hitCount.entrySet().stream()
.sorted(Map.Entry.<Document, Integer>comparingByValue().reversed())
.limit(topK)
.map(Map.Entry::getKey)
.toList();
}
/**
* 提取关键词:jieba INDEX 模式 + 正则提取英文/数字
*/
public Set<String> extractKeywords(String text) {
Set<String> keywords = new HashSet<>();
// ① jieba INDEX 模式:同时输出长词和细粒度词
List<SegToken> tokens = segmenter.process(text, JiebaSegmenter.SegMode.INDEX);
for (SegToken token : tokens) {
String word = token.word.trim();
if (word.length() >= 2 && !STOP_WORDS.contains(word)) {
keywords.add(word);
}
}
// ② 正则提取英文单词和数字串(订单号、版本号等)
Matcher matcher = ALPHANUMERIC_PATTERN.matcher(text);
while (matcher.find()) {
String word = matcher.group().toLowerCase();
if (word.length() >= 2 && !STOP_WORDS.contains(word)) {
keywords.add(word);
}
}
return keywords;
}
// 中英文停用词表
private static final Set<String> STOP_WORDS = Set.of(
"的", "了", "是", "在", "我", "有", "和", "就", "不", "人",
"都", "一", "一个", "这个", "那个", "可以", "什么", "怎么",
"the", "a", "an", "is", "are", "was", "were", "be", "been",
"to", "of", "in", "for", "on", "with", "at", "by", "from"
);
}
6.4 分词效果对比
原文:"退货流程通常需要3-7个工作日完成"
| 方案 | 提取的关键词 |
|---|---|
| n-gram | 退货, 货流, 流程, 程通, 通常, 常需... (噪音多) |
| jieba | 退货, 流程, 通常, 需要, 3-7, 工作日, 完成 ✅ |
七、完整项目实战
7.1 项目结构
enterprise-rag/
├── pom.xml
└── src/main/
├── java/com/example/rag/
│ ├── EnterpriseRagApplication.java
│ ├── controller/KnowledgeBaseController.java
│ ├── service/
│ │ ├── DocumentIngestionService.java
│ │ ├── HybridSearchService.java
│ │ └── RagAnswerService.java
│ ├── component/
│ │ ├── MultiFormatDocumentLoader.java
│ │ ├── SemanticChunkSplitter.java
│ │ ├── MetadataEnricher.java
│ │ └── KeywordIndex.java
│ └── config/RagConfig.java
└── resources/
├── application.properties
└── docs/
├── product-faq.md
└── return-policy.md
7.2 依赖配置 pom.xml
<?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
https://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.4.0</version>
</parent>
<groupId>com.example</groupId>
<artifactId>enterprise-rag</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>21</java.version>
<spring-ai.version>1.1.3</spring-ai.version>
</properties>
<repositories>
<repository>
<id>spring-milestones</id>
<url>https://repo.spring.io/milestone</url>
</repository>
</repositories>
<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>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring AI 智谱 GLM(Chat + Embedding) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-zhipuai</artifactId>
</dependency>
<!-- 向量数据库:Chroma -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-chroma</artifactId>
</dependency>
<!-- 文档读取:PDF、Markdown -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pdf-document-reader</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-markdown-document-reader</artifactId>
</dependency>
<!-- jieba 分词 -->
<dependency>
<groupId>com.huaban</groupId>
<artifactId>jieba-analysis</artifactId>
<version>1.0.2</version>
</dependency>
<!-- Lombok -->
<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>
7.3 配置文件
# ===== 智谱 GLM-4-Flash =====
spring.ai.zhipuai.api-key=your-api-key-here
spring.ai.zhipuai.chat.options.model=glm-4-flash
# ===== Chroma 向量数据库 =====
spring.ai.vectorstore.chroma.client.host=http://localhost
spring.ai.vectorstore.chroma.client.port=8000
spring.ai.vectorstore.chroma.collection-name=enterprise-kb
spring.ai.vectorstore.chroma.initialize-schema=true
# ===== 服务端口 =====
server.port=8080
# ===== 日志 =====
logging.level.com.example.rag=DEBUG
7.4 RAG 问答服务(核心代码)
@Service
public class RagAnswerService {
private final ChatClient chatClient;
private final HybridSearchService hybridSearch;
public RagAnswerService(ChatClient.Builder builder, HybridSearchService hybridSearch) {
this.hybridSearch = hybridSearch;
this.chatClient = builder
.defaultSystem("""
你是企业知识库助手。
请严格基于提供的上下文内容来回答问题。
如果上下文中没有相关信息,请明确说"知识库中暂无相关信息"。
""")
.build();
}
public AnswerResult answer(String question) {
// 1. 混合检索
List<Document> docs = hybridSearch.search(question, 4);
if (docs.isEmpty()) {
return new AnswerResult(question,
"知识库中暂无相关信息,请联系人工客服。", List.of(), 0);
}
// 2. 构建上下文
String context = docs.stream()
.map(doc -> String.format("【%s】\n%s",
doc.getMetadata().getOrDefault("source", "未知"),
doc.getText()))
.collect(Collectors.joining("\n\n---\n\n"));
// 3. 调用 AI
String answer = chatClient.prompt()
.user(String.format("根据以下内容回答问题:\n%s\n\n问题:%s", context, question))
.call()
.content();
List<String> sources = docs.stream()
.map(doc -> (String) doc.getMetadata().getOrDefault("source", "未知"))
.distinct()
.toList();
return new AnswerResult(question, answer, sources, elapsed);
}
public record AnswerResult(String question, String answer, List<String> sources, long elapsedMs) {}
}
7.5 REST API 控制器
@RestController
@RequestMapping("/kb")
public class KnowledgeBaseController {
private final DocumentIngestionService ingestionService;
private final RagAnswerService answerService;
public KnowledgeBaseController(DocumentIngestionService ingestionService,
RagAnswerService answerService) {
this.ingestionService = ingestionService;
this.answerService = answerService;
}
/** 问答接口 GET /kb/ask?q=退货需要几天? */
@GetMapping("/ask")
public RagAnswerService.AnswerResult ask(@RequestParam("q") String question) {
return answerService.answer(question);
}
/** 上传并摄入文档 POST /kb/ingest */
@PostMapping("/ingest")
public DocumentIngestionService.IngestionResult ingest(@RequestParam("file") MultipartFile file) {
Resource resource = new ByteArrayResource(file.getBytes()) {
@Override public String getFilename() { return file.getOriginalFilename(); }
};
return ingestionService.ingest(resource);
}
/** 初始化示例文档 POST /kb/init */
@PostMapping("/init")
public List<DocumentIngestionService.IngestionResult> init() {
return ingestionService.ingestDirectory("classpath:docs");
}
/** 统计信息 GET /kb/stats */
@GetMapping("/stats")
public Map<String, Object> stats() {
return Map.of(
"vectorStore", vectorStore.size(),
"keywordIndex", keywordIndex.getStats()
);
}
/** 清空索引 DELETE /kb/clear */
@DeleteMapping("/clear")
public void clear() {
keywordIndex.clear();
vectorStore.clear();
}
}
7.6 启动测试
# 1. 启动 Chroma 向量数据库(Docker)
docker run -d --name chroma -p 8000:8000 chromadb/chroma:latest
# 2. 配置智谱 API Key(或直接写入 application.properties)
export ZHIPUAI_API_KEY=your-api-key
# 3. 启动应用
mvn spring-boot:run
# 4. 初始化知识库
curl -X POST http://localhost:8080/kb/init
# 5. 提问测试
curl "http://localhost:8080/kb/ask?q=退货需要多少天?"
# 6. 上传新文档
curl -X POST http://localhost:8080/kb/ingest -F "file=@document.pdf"
7.7 效果展示
总结
本文实现了一套完整的企业级 RAG 管线,核心改进点:
| 改进项 | 原方案 | 新方案 | 效果 |
|---|---|---|---|
| 大模型 | 本地 Ollama | 智谱 GLM-4-Flash | 云端 API,无需本地部署 |
| 向量存储 | 内存 | Chroma | 持久化存储,支持大规模数据 |
| 关键词分词 | n-gram | jieba | 分词精准,噪音减少 |
| 文档分块 | 固定 Token | 语义分块 | 保留段落完整性 |
| 检索方式 | 纯向量 | 混合检索(RRF) | 兼顾语义 + 精确匹配 |
关注公众号「AI日撰」,点击菜单「获取源码」获取完整代码(Gitee 仓库)。