Java大模型开发入门 (8/15):连接外部世界(上) - RAG入门与文档加载
前言
在之前的文章中,我们已经成功地为AI助手赋予了记忆,让它能够进行流畅的多轮对话。然而,它的知识范围仅限于其训练数据截止日期前的公开信息。如果你问它:
- “我们公司最新的产品‘X-Wing’有什么特性?”
- “根据内部文档XYZ,服务器部署的第三步是什么?”
它只会回答:“对不起,我不知道。”
如何让大模型能够访问并利用我们私有的、最新的或领域特定的知识库(如产品手册、技术文档、项目Wiki)来回答问题呢?传统的**模型微调(Fine-tuning)**成本高、技术复杂,且不适合知识频繁更新的场景。
目前,业界最主流、最高效的解决方案是——RAG(Retrieval-Augmented Generation,检索增强生成)。从今天开始,我们将用三篇文章的篇幅,一步步在Java中构建一个完整的RAG系统。
第一部分:什么是RAG?它比“喂资料”更聪明
RAG的核心思想非常直观,它模仿了人类开卷考试的过程:
当被问到一个问题时,模型不会只凭自己的“记忆”回答。它会先去我们提供的知识库(一堆文档)里进行检索(Retrieval),找到与问题最相关的几段文字,然后将这些文字作为上下文(Context),连同原始问题一起,**增强(Augmented)地提交给大语言模型,最终让模型基于这些参考资料生成(Generation)**答案。
RAG的工作流程概览:
这个流程可以被拆解为两个阶段:
-
数据准备阶段(离线处理):
- 加载 (Load):从各种来源(文件、URL、数据库等)读取我们的私有文档。
- 分割 (Split):将长文档切分成更小的、有意义的文本块(Chunks)。
- 嵌入 (Embed):使用一个专门的“嵌入模型”将每个文本块转换成一个数学向量(一串数字),这个向量代表了文本块的语义。
- 存储 (Store):将文本块和其对应的向量存入一个专门的“向量数据库”中,以便快速检索。
-
问答阶段(在线处理):
- 嵌入用户问题:同样地,将用户的提问也转换成一个向量。
- 检索 (Retrieve):在向量数据库中,搜索与用户问题向量最“相似”的几个文本块向量。
- 增强 (Augment):将检索到的文本块(作为上下文)和原始问题组合成一个新的提示(Prompt)。
- 生成 (Generate):将这个增强后的提示发送给大语言模型,生成最终答案。
今天,我们将聚焦于数据准备阶段的前两个步骤:加载(Load)和分割(Split)。
第二部分:文档加载 (Loading) - 万物皆可为知识
LangChain4j提供了丰富的DocumentLoader
来帮助我们从各种来源加载数据。一个Document
对象在LangChain4j中不仅仅是文本内容,它还包含了元数据(Metadata),比如文件来源、作者等信息,这在后续的高级应用中非常有用。
实战:加载一个本地的.txt
或.pdf
文件
-
准备文档
在你的Spring Boot项目的src/main/resources
目录下,创建一个名为documents
的文件夹。在里面放入一个简单的.txt
文件,例如product-info.txt
。product-info.txt
内容示例:Product Name: X-Wing AI Assistant Version: 2.0 Release Date: 2025-06-15Key Features: - Multi-language support: English, Spanish, and Japanese. - Advanced memory module for long-term context retention. - Tool integration capability for calling external APIs. - Secure and private, all data is processed on-premise.Setup Instructions: The setup requires a minimum of 16GB RAM and a 4-core CPU. Java 17 is mandatory for running the application. The main configuration file is located at /etc/xwing/config.properties.
-
添加PDF加载依赖(可选)
如果想加载PDF,你需要添加Apache PDFBox的依赖到pom.xml
中:<dependency><groupId>org.apache.pdfbox</groupId><artifactId>pdfbox</artifactId><version>2.0.31</version> <!-- 或一个更新的版本 --> </dependency>
-
编写加载代码
让我们创建一个新的Service来处理文档。在service
包下创建DocumentService.java
。package com.example.aidemoapp.service;import dev.langchain4j.data.document.Document; import dev.langchain4j.data.document.loader.FileSystemDocumentLoader; import org.springframework.stereotype.Service;import java.nio.file.Path; import java.nio.file.Paths;@Service public class DocumentService {public void loadDocument() {// 获取项目根路径下的 "src/main/resources/documents/product-info.txt" 文件Path documentPath = Paths.get("src/main/resources/documents/product-info.txt");// 使用FileSystemDocumentLoader加载文档Document document = FileSystemDocumentLoader.loadDocument(documentPath);System.out.println("--- Document Loaded ---");System.out.println("Content: " + document.text());System.out.println("Metadata: " + document.metadata());} }
你可以创建一个临时的Controller或在主程序中调用这个
loadDocument
方法来测试,你会看到文件内容和元数据被成功打印出来。
第三部分:文档分割 (Splitting) - 化整为零的艺术
为什么要分割文档?
- Token限制:大模型一次能处理的文本长度(上下文窗口)是有限的。我们不能把一本几百页的书一次性塞给它。
- 检索精度:将文档切分成小块,可以让我们更精确地定位到与用户问题最相关的具体段落,而不是整个冗长的文档。
- 成本效益:发送给模型的文本越长,API调用成本越高。只发送最相关的片段可以有效降低成本。
LangChain4j提供了DocumentSplitter
接口和多种实现策略。最常用的一种是递归分割(Recursive Splitting)。它会尝试按顺序使用一组分隔符(如\n\n
, \n
,
, ``)来切分文本,以求在保持语义完整性的前提下,将文本块切分到指定的大小。
实战:分割我们加载的文档
修改DocumentService.java
:
package com.example.aidemoapp.service;import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.DocumentSplitter;
import dev.langchain4j.data.document.loader.FileSystemDocumentLoader;
import dev.langchain4j.data.document.parser.TextDocumentParser; // 明确指定解析器
import dev.langchain4j.data.document.splitter.DocumentSplitters;
import dev.langchain4j.data.segment.TextSegment;
import org.springframework.stereotype.Service;import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;@Service
public class DocumentService {public void loadAndSplitDocument() {Path documentPath = Paths.get("src/main/resources/documents/product-info.txt");// 对于txt文件,最好明确指定一个解析器Document document = FileSystemDocumentLoader.loadDocument(documentPath, new TextDocumentParser());// 创建一个文档分割器// 参数1: maxSegmentSize - 每个片段的最大字符数// 参数2: maxOverlap - 相邻片段之间的重叠字符数,有助于保持上下文连续性DocumentSplitter splitter = DocumentSplitters.recursive(300, 10);// 使用分割器来分割文档List<TextSegment> segments = splitter.split(document);System.out.println("--- Document Split into " + segments.size() + " Segments ---");for (int i = 0; i < segments.size(); i++) {System.out.println("--- Segment " + (i+1) + " ---");System.out.println(segments.get(i).text());}}
}
再次运行这个方法,你会看到原本一整篇的文档,被巧妙地分割成了多个长度在300个字符左右、且互相有少量重叠的文本片段(TextSegment
)。
总结
今天,我们成功地开启了构建RAG系统的第一步,完成了数据准备阶段最基础的加载和分割工作。我们学会了:
- RAG系统模仿人类“开卷考试”的核心思想。
- 使用
DocumentLoader
从文件系统加载我们的私有文档。 - 使用
DocumentSplitter
将长文档切分成有意义的、大小合适的文本片段。
现在我们有了一堆“知识碎片”,但计算机还无法理解它们的含义。如何让计算机能像人一样,理解“这个片段讲的是产品特性”和“那个片段讲的是安装要求”?
这需要我们将这些文本片段转换成机器能理解的语言——数学向量。这个过程,就是嵌入(Embedding)。
下一篇预告:
《Java大模型开发入门 (9/15):连接外部世界(中) - 向量嵌入与向量数据库》—— 我们将深入RAG的核心技术,理解什么是文本嵌入(Embedding),并学会使用嵌入模型将我们的文档片段转换为向量。同时,我们会初次接触“向量数据库”这个新概念,并将转换后的向量存入一个简单的内存向量库中。