LangChain4j RAG 实战:Java 后端如何把本地文档接入 Embedding 检索链路
很多 Java 后端第一次做 RAG,会先被一堆 Python 示例劝退:LangChain、LlamaIndex、向量库、Embedding、Retriever、Prompt 拼接......概念都懂,但真正落到 Java 项目里,经常不知道从哪一步开始。
这篇文章不讲大而全的企业知识库,只做一个最小可运行链路:
本地文档
-> 文档加载
-> 文本切分
-> Embedding 向量化
-> 存入向量库
-> 用户提问
-> 相似度检索
-> 拼接上下文
-> 调用大模型回答
目标是让 Java 后端工程师先跑通一条 RAG 主链路,再理解 demo 到生产之间还差什么。
说明:下面代码以 LangChain4j 官方 RAG / EmbeddingStore / DocumentLoader / AiServices 相关能力为基础,示例更偏工程骨架。不同 LangChain4j 版本的类名、构造器和包名可能略有差异,实际项目请以当前版本官方文档和 IDE 提示为准。
1. RAG 链路到底在解决什么问题
普通大模型调用一般是:
用户问题 -> Prompt -> 大模型 -> 回答
但如果问题依赖本地文档,比如:
- 公司内部接口文档;
- 项目 README;
- 产品说明书;
- 运维手册;
- 历史 FAQ;
模型本身并不知道这些内容。直接问模型,它只能猜。
RAG 的做法是:先从本地资料里找出和问题最相关的片段,再把这些片段作为上下文塞进 Prompt,让模型基于上下文回答。
可以把它理解成:
| 步骤 | 作用 |
|---|---|
| 文档加载 | 把本地 md/txt/pdf 等资料读进来 |
| 文本切分 | 避免一整篇文档太长,切成小片段 |
| Embedding | 把文本片段转成向量 |
| 向量检索 | 根据用户问题找相似片段 |
| Prompt 拼接 | 把检索结果放进模型上下文 |
| LLM 回答 | 基于资料回答,而不是凭空编 |
注意:RAG 不是让模型"记住"你的文档,而是每次提问时临时检索相关上下文。
2. Maven 依赖示例
LangChain4j 模块比较多,实际依赖会随你选择的模型供应商、向量库和文档类型变化。最小 demo 可以从下面几类依赖开始:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-bom</artifactId>
<version>请替换为当前稳定版本</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- LangChain4j 核心能力 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
</dependency>
<!-- OpenAI 兼容模型示例,也可以替换成你自己的模型供应商 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
</dependency>
<!-- 内存向量库,适合 demo,不适合生产持久化 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-embeddings</artifactId>
</dependency>
</dependencies>
如果你要接 PostgreSQL + pgvector、Milvus、Qdrant、Elasticsearch 等,需要再引入对应 integration 模块。
3. 准备一份本地文档
先不要一上来接复杂知识库。新建一个最简单的资料文件:
src/main/resources/docs/order-api.md
示例内容:
# 订单 API 说明
## 创建订单
POST /api/orders
请求字段:
- userId:用户 ID
- skuId:商品 SKU
- quantity:购买数量
返回字段:
- orderId:订单 ID
- status:订单状态
## 查询订单
GET /api/orders/{orderId}
如果订单不存在,返回 404。
后面的问题就围绕这份文档提问。
4. 文档加载与切分
RAG 的第一步是把文档读出来,再切成适合检索的小片段。
伪代码结构如下:
java
`Path docPath = Paths.get("src/main/resources/docs/order-api.md");
Document document = FileSystemDocumentLoader.loadDocument(docPath);
DocumentSplitter splitter = DocumentSplitters.recursive(
500, // 每个片段最大字符数
100 // 片段之间保留一点重叠,避免上下文断裂
);
List<TextSegment> segments = splitter.split(document);
System.out.println("segment size = " + segments.size());
`
切分参数没有绝对标准。
| 参数 | 过小的问题 | 过大的问题 |
|---|---|---|
| chunk size | 语义不完整,检索片段太碎 | 单个片段太长,占用上下文 |
| overlap | 上下文容易断 | 重复内容太多,成本升高 |
我的建议是:先用 300-800 字符做 demo,等真实文档接入后再根据命中效果调。
5. Embedding 与向量存储
接下来把每个文本片段转成向量,并存入 EmbeddingStore。
demo 阶段可以先用内存向量库:
java
`EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder()
.apiKey(System.getenv("OPENAI_API_KEY"))
.modelName("text-embedding-3-small")
.build();
EmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();
EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
.documentSplitter(splitter)
.embeddingModel(embeddingModel)
.embeddingStore(embeddingStore)
.build();
Document document = FileSystemDocumentLoader.loadDocument(docPath);
ingestor.ingest(document);
`
这一步做完以后,本地文档就被转成了可以检索的向量片段。
内存向量库只适合验证链路,因为应用重启后数据会丢。生产环境至少要考虑:
- 向量持久化;
- 文档增量更新;
- 文档版本管理;
- 删除文档后的向量清理;
- 向量库备份和权限控制。
6. 用户提问时如何检索
用户问题也需要转成向量,然后去 EmbeddingStore 里查相似片段。
示例结构:
java
`String question = "创建订单接口需要哪些请求字段?";
Embedding queryEmbedding = embeddingModel.embed(question).content();
EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()
.queryEmbedding(queryEmbedding)
.maxResults(3)
.minScore(0.6)
.build();
EmbeddingSearchResult<TextSegment> result = embeddingStore.search(request);
List<TextSegment> matchedSegments = result.matches().stream()
.map(EmbeddingMatch::embedded)
.toList();
`
这里有两个参数很关键:
| 参数 | 作用 | 建议 |
|---|---|---|
| maxResults | 最多返回几个片段 | demo 可先设 3-5 |
| minScore | 相似度低于多少就不要 | 需要结合数据调,不要盲目写死 |
如果 minScore 太低,模型会拿到很多无关资料;如果太高,可能检索不到任何上下文。
7. 拼接 Prompt 调用模型
检索到片段后,就可以把它们拼成上下文,让模型基于资料回答。
java
`String context = matchedSegments.stream()
.map(TextSegment::text)
.collect(Collectors.joining("\n---\n"));
String prompt = """
你是一个 Java 项目文档助手。
请只根据下面的资料回答问题。
如果资料中没有答案,请回答:文档中没有找到相关信息。
资料:
%s
问题:
%s
""".formatted(context, question);
ChatLanguageModel chatModel = OpenAiChatModel.builder()
.apiKey(System.getenv("OPENAI_API_KEY"))
.modelName("gpt-4o-mini")
.build();
String answer = chatModel.generate(prompt);
System.out.println(answer);
`
理想输出类似:
创建订单接口需要 userId、skuId 和 quantity 三个请求字段。
这段 Prompt 里最重要的不是语气,而是约束:
如果资料中没有答案,请回答:文档中没有找到相关信息。
否则 RAG 很容易变成"检索了资料,但模型继续自由发挥"。
8. 用 AiServices 封装成接口
如果不想每次手工拼 Prompt,可以用 LangChain4j 的 AiServices 思路,把问答能力封装成 Java 接口。
示例:
java
`interface DocAssistant {
String chat(String question);
}
`
然后把 ChatModel、EmbeddingStore、ContentRetriever 等组件组合起来:
java
`ContentRetriever retriever = EmbeddingStoreContentRetriever.builder()
.embeddingStore(embeddingStore)
.embeddingModel(embeddingModel)
.maxResults(3)
.minScore(0.6)
.build();
DocAssistant assistant = AiServices.builder(DocAssistant.class)
.chatLanguageModel(chatModel)
.contentRetriever(retriever)
.build();
String answer = assistant.chat("查询订单接口不存在时返回什么?");
`
这个封装更接近实际项目里的写法:Controller 调 Service,Service 调 Assistant,Assistant 背后完成检索和模型调用。
9. Spring Boot Controller 示例
可以把它封装成一个简单接口:
java
`@RestController
@RequestMapping("/rag")
public class RagController {
private final DocAssistant assistant;
public RagController(DocAssistant assistant) {
this.assistant = assistant;
}
@PostMapping("/ask")
public Map<String, String> ask(@RequestBody Map<String, String> body) {
String question = body.get("question");
String answer = assistant.chat(question);
return Map.of("answer", answer);
}
}
`
测试命令:
curl -X POST http://localhost:8080/rag/ask \
-H 'Content-Type: application/json' \
-d '{"question":"创建订单接口需要哪些请求字段?"}'
预期返回:
{
"answer": "创建订单接口需要 userId、skuId 和 quantity 三个请求字段。"
}
如果问一个文档里没有的问题:
curl -X POST http://localhost:8080/rag/ask \
-H 'Content-Type: application/json' \
-d '{"question":"订单接口支持微信支付回调吗?"}'
理想回答应该是:
文档中没有找到相关信息。
如果模型开始编支付回调字段,说明你的 Prompt 约束或检索结果处理还不够严。
10. demo 到生产还差什么
最小 RAG demo 跑通以后,不要急着说"知识库完成了"。生产至少还要补这些能力:
| 生产问题 | 说明 |
|---|---|
| 文档同步 | 文档新增、修改、删除后,向量如何增量更新 |
| 权限隔离 | 不同用户能不能检索同一批资料 |
| 引用来源 | 回答里最好带文档名、章节、片段来源 |
| 召回质量 | 检索不到、检索错、召回过多都要评估 |
| 成本控制 | Embedding、向量库、Chat Model 都有成本 |
| 可观测性 | 需要记录 query、命中片段、score、耗时 |
| 安全边界 | 不要把敏感文档随便进入 Prompt |
尤其是权限问题,很多 demo 会忽略。
如果你的向量库里混了多部门资料,但检索时没有按用户权限过滤,那么 RAG 可能会变成数据泄露入口。
11. 常见坑
1)文档切得太碎
切得太碎,模型拿到的片段可能只有半句话,回答就会缺上下文。
解决办法:调大 chunk size,或者增加 overlap。
2)文档切得太大
切得太大,检索结果虽然完整,但会占用大量上下文,模型容易抓不到重点。
解决办法:先按标题、段落、章节切,再做递归切分。
3)只看回答,不看命中片段
RAG 调试时一定要打印命中片段和 score。
java
`for (EmbeddingMatch<TextSegment> match : result.matches()) {
System.out.println("score = " + match.score());
System.out.println(match.embedded().text());
}
`
如果命中片段本身就错了,后面 Prompt 写得再好也救不回来。
4)没有"不知道"机制
如果资料里没有答案,模型应该明确说没有找到,而不是自由发挥。
这需要在 Prompt、检索阈值、后处理里一起约束。
5)demo 用内存向量库,生产也照搬
InMemoryEmbeddingStore 适合学习和验证链路,不适合生产。
生产建议根据场景选择 pgvector、Qdrant、Milvus、Elasticsearch 等,并补充备份、权限和监控。
12. 小结
用 Java 做 RAG,不一定要先引入复杂平台。最小链路其实就五件事:
加载文档 -> 切分文本 -> Embedding -> 向量检索 -> 拼 Prompt 回答
LangChain4j 的价值在于,它把这些步骤都放回 Java 工程体系里:你可以用熟悉的 Maven、Spring Boot、Controller、Service 和配置管理方式,把 AI 检索问答接进后端项目。
但要记住:跑通 demo 只是第一步。
真正上线前,重点不是"模型会不会回答",而是:
- 检索命中的资料对不对;
- 没有资料时会不会拒答;
- 用户权限有没有过滤;
- 文档更新后向量有没有同步;
- 回答是否可追溯到原文。
如果这几个问题没解决,RAG 很容易从"知识库助手"变成"更会编的搜索框"。
对 Java 后端来说,建议先用 LangChain4j 把这条最小链路跑通,再逐步替换为持久化向量库、引入权限过滤和可观测性。这样比一开始就堆完整平台更稳。
如果你正在做 Java AI 工程落地,可以先从这类最小 RAG demo 开始,把每个环节的输入、输出和失败边界都看清楚。