我是一名从 Java 后端转型 AI 应用开发的工程师。这篇文章是我把 ACP 课程里 RAG 章节吃透后,按「可复习、可落地、可扩展」整理出来的技术分享稿。
本文全部用 Java + Spring AI + Spring AI Alibaba 来实现。
一、我为什么要学 RAG(Retrieval Augmented Generation)
我在做企业 AI 应用时,最先遇到的现实问题不是“模型不聪明”,而是“模型不知道我公司的内部知识”。
比如制度文档、流程规范、项目手册,模型预训练里并没有。
所以我理解 RAG 的核心价值是:
- 不是让模型“背更多知识”,而是让它在回答前“先查资料再作答”;
- 这本质上是 上下文工程(Context Engineering);
- 目标是把“最相关、最可信、最精简”的上下文喂给模型。
术语白话解释(先看这个再往下读)
- RAG(检索增强生成):先“查资料”,再“按资料回答”。
- 上下文(Context):当前这次问答里,模型能看到的信息。
- 上下文工程(Context Engineering):不是乱塞信息,而是挑“刚刚好、有用”的信息给模型。
- Embedding(向量化):把一句话变成一串数字,方便机器比较“语义像不像”。
- Vector Store(向量库):专门存这些“数字向量”的数据库,支持快速相似检索。
- Chunk(切块):把长文拆成小段,便于召回。
- Overlap(重叠):相邻切块保留一部分重复内容,避免语义被硬切断。
- TopK:从候选结果里取前 K 条最相关内容。
- Cross-Encoder(交叉编码器):把“问题+文档”一起判断相关度,比只看向量距离更准,但更耗时。
- 重排(Rerank):对初次召回结果再做一次更精细排序,提升答案质量。
二、RAG 工作流(我自己的工程化理解)
我把 RAG 分成两个阶段:索引构建 和 检索生成。
2.1 索引构建(离线/准实时)
- 文档解析:PDF/Word/Markdown/HTML ->
Document - 文本切块:长文本切成 chunks(支持 overlap)
- 向量化:chunk -> embedding vector
- 索引落库:写入 Vector Store(Milvus/PGVector/Elasticsearch 等)
白话理解:索引构建就像“提前做目录和标签”,问问题时就不用每次翻整本书。
2.2 检索生成(在线)
- 用户问题入参
- 多路召回(向量 + 关键词)
- Cross-Encoder 二阶段重排
- 组装 Prompt(问题 + TopK 证据)
- 大模型生成答案(可附 citation)
白话理解:在线阶段就是“临场开卷考试”。
先找资料(检索),再组织语言回答(生成)。
三、文本切块:我在 Spring AI 里的实战策略
你提到的点非常关键:
Spring AI 内置 4 种标准切块策略,且都支持 overlap 思路(通过参数控制分块重叠)。
我在项目中通常这样选:
- 结构化文档(制度、规范)优先:按标题/段落边界切
- 非结构化长文:TokenTextSplitter(按 token)
- FAQ/短段知识:较小 chunk + 适度 overlap
- 代码/配置类文档:按语义段落切,减少跨段噪声
经验参数(可作为起点):
-
chunkSize: 500~1000 tokens -
overlap: 50~150 tokens -
topK: 8~20(看语料质量)
白话理解参数:
chunkSize太小:信息碎片化,回答容易断;chunkSize太大:噪声多,检索不准;overlap太小:上下文断裂;太大:重复内容过多、成本上升。
四、Java 版 RAG 的实现
下面我给出可直接迁移的 Java 版本示例(Spring Boot 风格)。
4.1 Maven 依赖(Spring AI + Alibaba)
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-milvus</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
4.1.1 文档解析:我认为更成熟的依赖组合
只靠单一解析器在企业场景里通常不够。我现在更常用的是 Tika + 格式专用库 + OCR 兜底 的分层方案:
- Apache Tika:统一入口,覆盖 PDF/Word/Excel/PPT/HTML/RTF 等;
- PDFBox:处理复杂 PDF(页码、版面、加密、抽图);
- Apache POI:Office 文档的细粒度解析(表格、批注、sheet);
- Tess4J/Tesseract(可选):扫描件、图片型 PDF 的 OCR;
- JSoup(可选):HTML 清洗,去脚本和噪声标签;
- Jackson/JSONPath(可选):结构化文档(JSON/JSONL)解析。
对应依赖可以这样加(按需启用,不必一次全上):
<dependencies>
<!-- Spring AI 文档读取基础 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-core</artifactId>
</dependency>
<!-- 通用文档解析 -->
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-parsers-standard-package</artifactId>
</dependency>
<!-- PDF 深度处理 -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
</dependency>
<!-- Office 深度处理 -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
</dependency>
<!-- HTML 清洗(可选) -->
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
</dependency>
</dependencies>
简单选型建议:
- 刚起步:
Tika + PDFBox足够;- Office 文档很多:再加
POI;- 扫描件很多:再加
OCR;- 网页知识多:再加
JSoup。
4.2 配置(application.yml)
spring:
ai:
model:
chat: openai
embedding: dashscope
openai:
api-key: ${OPENAI_API_KEY:}
base-url: ${OPENAI_BASE_URL:https://dashscope.aliyuncs.com/compatible-mode}
chat:
options:
model: qwen-plus
dashscope:
api-key: ${DASHSCOPE_API_KEY:}
embedding:
enabled: true
options:
model: text-embedding-v3
4.3 建索引( from_documents)
@Service
public class RagIndexService {
private final VectorStore vectorStore;
private final DocumentReader documentReader;
public RagIndexService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
this.documentReader = new TikaDocumentReader(new FileSystemResource("docs/company_policy.pdf"));
}
public void indexing() {
// 解析后的原始文档集合(通常一篇长文档也可能被 reader 拆成多个 Document)
List<Document> docs = documentReader.get();
// 1) 切块(示例:TokenTextSplitter)
// chunkSize: 目标分块大小(越大上下文越完整,但召回噪声可能增加)
// minChunkSizeChars: 最小字符阈值,避免碎片块过小
// minChunkLengthToEmbed: 过短文本不做向量化,降低无效向量
// maxNumChunks: 单文档最大切块上限,防止异常文档导致切块失控
// keepSeparator: 保留分隔符(如换行/段落边界),提升语义连续性
TokenTextSplitter splitter = TokenTextSplitter.builder()
.withChunkSize(800)
.withMinChunkSizeChars(200)
.withMinChunkLengthToEmbed(10)
.withMaxNumChunks(10_000)
.withKeepSeparator(true)
.build();
// 将原始 docs 按切块规则展开为可向量化的小片段
List<Document> chunks = splitter.apply(docs);
// 2) 写入向量库(内部会调用 EmbeddingModel 把 chunk 文本转向量后存储)
vectorStore.add(chunks);
}
}
说明:实际项目里我会做多文档目录扫描(PDF/MD/DOCX),并在 metadata 里打上
source/file/page/title,后续做引用和过滤会非常有用。
4.3.1 文档解析增强版(多格式 + 元数据 + OCR 兜底)
下面这个示例是我在生产里常用的“可扩展解析管线”思路:先按扩展名走专用解析器,失败再回退 Tika,最后可接 OCR。
@Service
public class EnterpriseDocumentParseService {
public List<Document> parse(Path path) {
String name = path.getFileName().toString().toLowerCase();
if (name.endsWith(".pdf")) {
return parsePdf(path);
}
if (name.endsWith(".docx") || name.endsWith(".doc") || name.endsWith(".xlsx") || name.endsWith(".pptx")) {
return parseOfficeByTika(path);
}
if (name.endsWith(".md") || name.endsWith(".txt")) {
return List.of(new Document(readUtf8(path), Map.of("source", path.toString(), "type", "text")));
}
if (name.endsWith(".html") || name.endsWith(".htm")) {
String clean = org.jsoup.Jsoup.parse(readUtf8(path)).text();
return List.of(new Document(clean, Map.of("source", path.toString(), "type", "html")));
}
return parseByTikaFallback(path);
}
private List<Document> parsePdf(Path path) {
try (PDDocument pdf = PDDocument.load(path.toFile())) {
PDFTextStripper stripper = new PDFTextStripper();
List<Document> out = new ArrayList<>();
for (int page = 1; page <= pdf.getNumberOfPages(); page++) {
stripper.setStartPage(page);
stripper.setEndPage(page);
String text = stripper.getText(pdf);
if (text == null || text.isBlank()) {
// 这里可以接 OCR:把该页渲染成图片后识别
text = "[OCR占位] 当前页无可提取文本,可接入 Tess4J/云OCR";
}
out.add(new Document(text, Map.of(
"source", path.toString(),
"page", String.valueOf(page),
"type", "pdf"
)));
}
return out;
} catch (Exception e) {
return parseByTikaFallback(path);
}
}
private List<Document> parseOfficeByTika(Path path) {
return parseByTikaFallback(path);
}
private List<Document> parseByTikaFallback(Path path) {
Resource resource = new FileSystemResource(path);
TikaDocumentReader reader = new TikaDocumentReader(resource);
return reader.get().stream()
.map(doc -> new Document(doc.getText(), merge(doc.getMetadata(),
Map.of("source", path.toString(), "type", "tika-fallback"))))
.toList();
}
private static Map<String, Object> merge(Map<String, Object> a, Map<String, Object> b) {
Map<String, Object> m = new HashMap<>(a == null ? Map.of() : a);
m.putAll(b);
return m;
}
private static String readUtf8(Path path) {
try {
return Files.readString(path, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
我通常会把这个解析服务接到 RagIndexService#indexing() 之前,再统一走 split + embedding + vectorStore。
4.3.2 结合宝妈 AI 平台源码:建立索引四步完整落地
下面这段是把你给的教学四步,映射到我们项目里的真实实现(module-rag):
- 文档解析:
RagIngestApplicationService#ingestBinary->KnowledgeDocumentParsePort#parse(...) - 文本分段:
TextChunker.chunk(...)(两阶段:父子切块 + 递归切块) - 文本向量化:
vectorStore.add(toEmbed)(Spring AI 在内部调用 embedding 模型) - 存储索引:向量写 Milvus + 对账数据写 MySQL(
rag_document/rag_chunk,并维护embedding_status)
对应核心代码示例如下(节选自当前项目):
@Service
public class RagIngestApplicationService {
@Transactional(rollbackFor = Exception.class)
public long ingestBinary(byte[] bytes, String filename, RagIngestTextCommand metadata) {
// 1) 文档解析(pdf/docx/xlsx/pptx/html/md/json 等走统一 parse 路由)
ParsedKnowledgeDocument parsed = knowledgeDocumentParsePort.parse(
bytes, filename, metadata.sourceUrl(), metadata.category());
RagDocumentDO doc = new RagDocumentDO();
doc.setTitle(metadata.title());
doc.setSourceType("binary");
ragDocumentMapper.insert(doc);
List<Document> toEmbed = new ArrayList<>();
int idx = 0;
for (ParsedKnowledgeSegment segment : parsed.segments()) {
if (segment == null || segment.text() == null || segment.text().isBlank()) continue;
// 2) 文本分段(项目内的两阶段分块)
List<String> pieces = TextChunker.chunk(
segment.text(),
ragProperties.getChunkSize(),
ragProperties.getChunkOverlap());
for (String piece : pieces) {
RagChunkDO row = new RagChunkDO();
row.setDocumentId(doc.getId());
row.setChunkIndex(idx++);
row.setContentText(piece);
row.setEmbeddingStatus("PENDING");
ragChunkMapper.insert(row); // 对账表先落库
Map<String, Object> meta = new HashMap<>();
meta.put("chunkId", row.getId());
meta.put("documentId", doc.getId());
meta.put("file", filename);
toEmbed.add(Document.builder().text(piece).metadata(meta).build());
}
}
// 3) 文本向量化 + 4) 存储索引(VectorStore -> Milvus)
if (!toEmbed.isEmpty()) {
vectorStore.add(toEmbed);
for (Document d : toEmbed) {
Object cid = d.getMetadata().get("chunkId");
if (cid instanceof Number n) {
ragChunkMapper.updateEmbeddingDone(n.longValue()); // PENDING -> EMBEDDED
}
}
}
return doc.getId();
}
}
分段器 TextChunker 也不是简单 substring,而是更适合知识库的“先结构后 token”策略:
public final class TextChunker {
public static List<String> chunk(String text, int chunkSize, int overlap) {
List<String> paragraphBlocks = splitByParagraphOrHeading(text); // 标题/段落边界优先
TokenTextSplitter splitter = TokenTextSplitter.builder()
.withChunkSize(chunkSize)
.withMinChunkSizeChars(Math.max(50, chunkSize - overlap))
.withKeepSeparator(true)
.build();
// 超长段再二次切分,避免语义断裂和超长 chunk
// ... 省略拼接逻辑
}
}
4.4 查询问答
@Service
public class RagQueryService {
private final ChatClient chatClient;
private final VectorStore vectorStore;
public RagQueryService(ChatClient.Builder chatClientBuilder, VectorStore vectorStore) {
this.chatClient = chatClientBuilder.build();
this.vectorStore = vectorStore;
}
public String ask(String question) {
// 1) 检索
SearchRequest request = SearchRequest.builder()
.query(question)
.topK(10)
.similarityThreshold(0.65)
.build();
List<Document> recalled = vectorStore.similaritySearch(request);
// 2) 组装上下文
String context = recalled.stream()
.map(Document::getText)
.reduce((a, b) -> a + "\n\n---\n\n" + b)
.orElse("无可用知识片段");
// 3) 生成
String prompt = """
请仅基于以下知识片段回答问题;如果知识不足,请明确说“知识库未覆盖”。
【知识片段】
%s
【用户问题】
%s
""".formatted(context, question);
return chatClient.prompt().user(prompt).call().content();
}
}
五、索引持久化:我在 Java 里的做法
Java 里我更建议直接持久化到真正的向量库(Milvus/PGVector),这样天然具备可重启、可扩容、可多实例访问能力。
如果一定要做本地缓存,我会:
- 原文切块结果落本地 JSONL(可复算)
- 向量索引落 Milvus(在线查询)
- 增量更新基于
doc_hash做幂等写入
术语补充:
- 幂等:同一份文档重复导入多次,结果不应该重复脏写。
- doc_hash:文档指纹(内容摘要),常用于判断“内容是否变化”。
六、RAG 多轮对话:我如何做“问题改写”
课程里提到 CondenseQuestionChatEngine,这个思想非常对:
第二轮问“他的主管是谁?”时,必须改写成“张三的主管是谁?”再检索。
Java 里我会拆成两个链路:
- Rewrite Chain:历史 + 当前问题 -> 独立问题
- RAG Chain:独立问题 -> 检索 -> 回答
白话理解:先把“他说的这个/那个”改写成完整句,再去检索,准确率会明显提升。
@Service
public class QuestionRewriteService {
private final ChatClient chatClient;
public QuestionRewriteService(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
public String rewrite(List<String> history, String currentQuestion) {
String historyText = String.join("\n", history);
String prompt = """
你是检索优化助手。请将后续问题改写为独立问题,保留必要上下文。
对话历史:
%s
后续问题:
%s
只输出改写后的问题,不要解释。
""".formatted(historyText, currentQuestion);
return chatClient.prompt().user(prompt).call().content();
}
}
七、Cross-Encoder 重排
你提到“向量 + 关键词多路召回后,用 Cross-Encoder 做二阶段重排”,这是成熟方案,我非常认同。
我在工程里的标准流程:
- 向量召回 topN(比如 30)
- 关键词召回 topN(比如 30)
- 去重合并
- Cross-Encoder 对
(query, doc)打分 - 取 topK(比如 8)进入最终 Prompt
白话理解:第一轮召回像“海选”,第二轮重排像“复试”。
海选保证不漏,复试保证更准。
7.1 重排接口设计(Java)
public interface RerankService {
List<DocumentScore> rerank(String query, List<Document> candidates, int topK);
record DocumentScore(Document document, double score) {}
}
7.2 一个可落地的实现建议
- 若你用阿里云生态,可优先看是否有可直接调用的 rerank 能力;
- 若暂时没有,先用“轻量打分模型 + 规则融合”过渡;
- 最终目标是独立 Cross-Encoder 服务化,便于 A/B。
八、我会怎么做一套“更成熟”的 Java RAG 架构
如果从“课程 Demo”升级到“生产可用”,我会加这 7 件事:
- 多路召回:Vector + BM25 + Metadata Filter
- 二阶段重排:Cross-Encoder
- 引用溯源:答案携带 source/page
- 查询改写:多轮上下文消歧
- 缓存层:Query Cache + Embedding Cache
- 可观测性:trace 每一步耗时与得分
- 安全治理:敏感词、越权文档过滤、提示注入防护
这些词到底在做什么(极简版)
- Metadata Filter:先按部门/权限/时间筛一遍,再做语义检索。
- Citation(引用溯源):回答后附“证据来自哪一页哪一段”。
- Faithfulness(忠实度):回答是否真的基于检索证据,而不是模型“编”出来。
- Prompt 注入:用户故意诱导模型忽略规则(例如“忽略以上所有限制”)。
- 可观测性(Observability):能看到每一步耗时、命中率、失败点,便于定位问题。
九、给同样是 Java 转型 AI 的我自己的复盘清单
每次做 RAG,我都按这张清单复习:
- 文档是否高质量解析(结构没丢)
- 切块参数是否按语料调优(不是固定模板)
- 召回是否做了多路 + 重排
- Prompt 是否明确“仅基于证据回答”
- 多轮是否做问题改写
- 输出是否可追溯(引用来源)
- 是否有离线评测集而不是只看主观效果
十、总结:我从 ACP 学到的真正关键
对我这个 Java 工程师来说,ACP 的价值不只是“跑通一个 RAG Demo”,而是建立一套方法论:
- RAG 是上下文工程,不是“万能外挂”;
- 检索质量决定上限,生成只是放大器;
- 生产级 RAG 一定是“召回 + 重排 + 约束生成 + 可观测”。
如果你和我一样在做 Java 向 AI 应用开发转型,我建议先把这篇里的 Java 骨架跑通,再逐步加重排、评测和治理。
这样你会从“会调模型”走向“能交付 AI 系统”。