从零实现一个Java版RAG系统
· 5 min read
前言
上一篇文章介绍了 RAG(Retrieval-Augmented Generation)的整体架构,以及为什么它已经成为企业 AI 应用的标配。
本篇将通过 Java 实战,从零实现一个完整的 RAG 系统,包括:
- 文档解析
- Chunk 切分
- Embedding 向量生成
- Qdrant 存储
- 向量检索
- 大模型生成最终答案
最终实现如下流程:
用户问题
↓
Embedding
↓
Qdrant向量检索
↓
获取相关知识片段
↓
Prompt组装
↓
LLM生成答案
技术栈:
- Spring Boot 3
- LangChain4j
- OpenAI Compatible API
- Qdrant
- Apache POI
- PDFBox
1. 文档解析
企业知识库中的数据通常来自:
- Word
- Markdown
- Wiki
- Excel
第一步需要将这些文档统一转换成纯文本。
PDF解析
使用 PDFBox:
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.2</version>
</dependency>
public String parsePdf(File file) throws IOException {
try (PDDocument document = Loader.loadPDF(file)) {
PDFTextStripper stripper = new PDFTextStripper();
return stripper.getText(document);
}
}
Word解析
使用 Apache POI:
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
</dependency>
public String parseWord(File file) throws Exception {
try (XWPFDocument document =
new XWPFDocument(new FileInputStream(file))) {
return document.getParagraphs()
.stream()
.map(XWPFParagraph::getText)
.collect(Collectors.joining("\n"));
}
}
Markdown解析
Markdown最简单:
String content = Files.readString(
Path.of("rag.md"));
统一抽象:
public interface DocumentParser {
String parse(File file);
}
这样后续新增 Excel、HTML、Wiki 解析器也非常方便。
2. Chunk切分策略
大模型无法一次读取几十万字文档。
因此需要将文档拆分成多个 Chunk。
Fixed Size
最简单方案:
每500字符
切一次
示例:
public List<String> split(
String text,
int chunkSize) {
List<String> chunks = new ArrayList<>();
for (int i = 0; i < text.length(); i += chunkSize) {
chunks.add(
text.substring(
i,
Math.min(
i + chunkSize,
text.length()
)
)
);
}
return chunks;
}
优点:
- 实现简单
- 速度快
缺点:
- 容易截断语义
Sliding Window
生产环境最常见。
Chunk1
[1-500]
Chunk2
[400-900]
Chunk3
[800-1300]
重叠部分保留上下文。
public List<String> split(
String text,
int chunkSize,
int overlap) {
List<String> chunks = new ArrayList<>();
for (int i = 0;
i < text.length();
i += chunkSize - overlap) {
chunks.add(
text.substring(
i,
Math.min(
i + chunkSize,
text.length()
)
)
);
}
return chunks;
}
推荐:
Chunk Size = 500~1000 Token
Overlap = 100~200 Token
Semantic Chunk
按照语义切分。
例如:
# 商品评价系统
介绍...
# AI回复系统
介绍...
切分结果:
Chunk1
商品评价系统
Chunk2
AI回复系统
LangChain4j 已经提供相关实现:
DocumentSplitter splitter =
DocumentSplitters.recursive(
500,
100
);
企业知识库一般采用:
语义切分
+
Sliding Window
效果最佳。
3. Embedding生成
Chunk完成后,需要生成向量。
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
</dependency>
配置Embedding模型:
@Bean
EmbeddingModel embeddingModel() {
return OpenAiEmbeddingModel.builder()
.baseUrl(baseUrl)
.apiKey(apiKey)
.modelName("text-embedding-v4")
.build();
}
生成向量:
Embedding embedding =
embeddingModel.embed(text)
.content();
float[] vector = embedding.vector();
返回结果:
[0.123,
0.555,
-0.233,
...]
通常维度:
768
1024
1536
3072
4. Qdrant存储
向量数据库负责保存:
向量
+
原文
+
元数据
启动Qdrant:
version: "3"
services:
qdrant:
image: qdrant/qdrant
ports:
- "6333:6333"
启动:
docker compose up -d
创建Collection:
client.createCollectionAsync(
"knowledge",
VectorParams.newBuilder()
.setSize(1536)
.setDistance(Distance.Cosine)
.build()
);
写入向量:
PointStruct point =
PointStruct.newBuilder()
.setId(
PointIdFactory.id(
UUID.randomUUID().toString()
)
)
.setVectors(
VectorsFactory.vectors(vector)
)
.putAllPayload(
Map.of(
"content",
ValueFactory.value(text)
)
)
.build();
client.upsertAsync(
"knowledge",
List.of(point)
);
存储结构:
ID
Vector
Content
Source
Title
CreateTime
5. 向量检索
用户提问:
商品评价AI回复如何实现?
先转成向量:
Embedding queryEmbedding =
embeddingModel.embed(question)
.content();
执行相似度搜索:
SearchPoints searchPoints =
SearchPoints.newBuilder()
.setCollectionName("knowledge")
.addAllVector(
Floats.asList(
queryEmbedding.vector()
)
)
.setLimit(5)
.build();
List<ScoredPoint> points =
client.searchAsync(searchPoints)
.get();
获得结果:
Chunk A
Score = 0.91
Chunk B
Score = 0.88
Chunk C
Score = 0.85
取Top-K:
Top3
Top5
Top10
一般:
Top5
即可。
6. 最终回答生成
检索出的知识需要注入Prompt。
构造上下文:
String context = chunks.stream()
.collect(Collectors.joining("\n"));
Prompt模板:
String prompt = """
你是企业知识库助手。
请严格依据提供的知识回答。
知识:
%s
问题:
%s
如果知识中没有答案,
请明确说明不知道。
""".formatted(
context,
question
);
调用大模型:
String answer =
chatModel.chat(prompt)
.aiMessage()
.text();
最终效果:
问题:
商品评价AI回复如何实现?
回答:
系统首先进行情感识别,
判断评价属于好评还是差评。
对于差评进一步进行意图识别,
例如物流慢、商品质量问题、
水果不甜等。
随后检索对应知识库,
结合Prompt模板生成回复。
完整流程代码
public String ask(String question) {
// 1. 问题向量化
Embedding queryEmbedding =
embeddingModel.embed(question)
.content();
// 2. Qdrant检索
List<String> chunks =
vectorStore.search(
queryEmbedding.vector(),
5
);
// 3. 拼接上下文
String context =
String.join("\n", chunks);
// 4. 构造Prompt
String prompt = """
请依据知识回答问题
知识:
%s
问题:
%s
"""
.formatted(
context,
question
);
// 5. LLM生成
return chatModel.chat(prompt)
.aiMessage()
.text();
}
整体架构:
文档
↓
解析
↓
Chunk
↓
Embedding
↓
Qdrant
↓
向量检索
↓
Prompt增强
↓
LLM回答
总结
一个完整的 RAG 系统,本质上只包含六个核心步骤:
- 文档解析(PDF/Word/Markdown)
- Chunk切分
- Embedding向量生成
- Qdrant存储
- 向量检索
- LLM生成答案
对于企业级应用,还会继续增加以下能力:
- Hybrid Search(关键词+向量检索)
- ReRank重排序
- 多路知识库路由
- Metadata过滤
- Query Rewrite
- 多轮会话记忆
- 文档增量更新
- 检索结果评估