LangChain4j RAG 流水线:手写问答全流程,给 AI 开卷考试
作者:鱼宵 | LangChain4j实战精通营 · 第 8 篇
公司 HR 把一本 20 页的员工手册扔给你:"做个问答机器人吧,新人问年假怎么请,它照着手册答。"你直接接大模型问"年假怎么请",它张口就来:"年假一般 10 天,提前一周申请。"------手册里明明写的是"满 1 年 5 天、提前 3 个工作日走 OA"。它记岔了,还一脸笃定。
这就是闭卷考试的下场:模型凭训练记忆答题,公司文档它根本没看过。解法是 RAG------先把手册翻到对应那页,连问题一起塞给模型,让它开卷照着写 。这篇文章带你手写一条完整的 RAG 流水线,启动时自动把 FAQ 切分入库,问"年假怎么请"它照着资料答,问"年会什么时候开"(手册里没有)它真的会拒答。代码全在仓库 lesson-08/,clone 下来跑一遍,端口 8089 起个服务,两个问题一比就懂。
一、核心原理:开卷考试分五步
一句话:RAG(检索增强生成)不让模型凭记忆答题,而是先从知识库把相关资料翻出来,连同问题塞给模型,让它照着资料写答案。
类比给实习生开卷考试:
| 步骤 | 干什么 | 类比 |
|---|---|---|
| ① 加载 Load | 把 faq.md 读成 Document | 把手册拿到手 |
| ② 切分 Split | 按段落切成小块 chunk | 厚手册裁成一张张小抄 |
| ③ 嵌入 Embed | 每块文本算成向量(384 维) | 给每张小抄做语义指纹 |
| ④ 入库 Store | 向量+原文成对存进向量库 | 小抄按指纹排进档案柜 |
| ⑤ 检索增强生成 | 问题向量化→找最像 top-3→拼提示词→模型照资料答 | 提问时先翻出相关小抄递过去 |
前四步在服务启动时跑一次,第五步每次提问都跑。上一课我们只做了"几句话转向量算相似度"这个最小内核,这一课把它接成一个能对外服务的问答程序。
为什么文档要切分
两个原因:一是上下文窗口装不下几百页文档,整篇塞进去既超 token 又烧钱;二是检索精度------整篇文档和问题"有点相关",切碎后只有真正相关那一段会被捞出来。本课 chunkSize=300(每块约 300 token)、overlap=30(相邻块重叠 30 token):太大一块混多个话题检索不准,太小一条 FAQ 被拦腰切断;重叠就是防止一句话从中间被撕成两半。
治幻觉的三道闸
RAG 不是加了检索就万事大吉,本课实测发现三道闸缺一不可:
- 低温 :
temperature=0.3,不让模型自由发挥; - 提示词强约束:"只根据资料答,资料没有就明说,不许编";
- 相似度门槛:检索不到任何相关片段就不调模型,直接回固定兜底话术。
这里有个反直觉的实测发现,先剧透:本例真正挡住瞎编的其实是第 ② 道闸(模型照提示词自我判断),不是门槛------原因看第四节。
二、动手:起服务,发两个问题
环境:Windows + JDK 17 + 已配
DEEPSEEK_API_KEY。这是 Web 课,端口 8089。
powershell
# 1) 环境检查
java -version
[bool][Environment]::GetEnvironmentVariable('DEEPSEEK_API_KEY')
Get-NetTCPConnection -LocalPort 8089 -State Listen -ErrorAction SilentlyContinue # 无输出=端口空闲
# 2) 编译
cd langchain4j-journey\lesson-08
$env:JAVA_HOME="C:\Program Files\Java\jdk-17"
mvn clean install -DskipTests
# 3) 启动(必须在 lesson-08 目录下,docs/faq.md 是相对路径)
java -Dfile.encoding=UTF-8 -jar target\lesson-08-1.0.0.jar
看到 Tomcat started on port 8089 + [RAG] 第3/4步 嵌入+入库完成 就成了。这个窗口会一直挂着,新开一个终端测接口。中文参数先 URL 编码:
powershell
# 文档内问题:年假怎么请(应照资料答 + references 非空)
curl.exe -o out.json "http://localhost:8089/faq/chat?q=$([uri]::EscapeDataString('年假怎么请?'))"
Get-Content out.json -Encoding UTF8
# 文档外问题:年会什么时候开(应拒答)
curl.exe -o out2.json "http://localhost:8089/faq/chat?q=$([uri]::EscapeDataString('年会什么时候开?'))"
Get-Content out2.json -Encoding UTF8
首次启动会下载 ONNX 嵌入模型约 90MB,耐心等一次,之后走本地缓存。PowerShell 直接打印 JSON 中文会乱码,所以用
curl.exe -o落盘再按 UTF-8 读。
三、关键代码:手写五步,不套黑盒
本课核心就是 RagService------我没套 EasyRAG 那种黑盒 starter,就是为了让你看清每一步。完整贴出来:
java
package com.langchain4j.lesson08;
import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.Metadata;
import dev.langchain4j.data.document.splitter.DocumentSplitters;
import dev.langchain4j.data.embedding.Embedding;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.internal.Json;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.store.embedding.EmbeddingMatch;
import dev.langchain4j.store.embedding.EmbeddingSearchRequest;
import dev.langchain4j.store.embedding.EmbeddingSearchResult;
import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* RAG 问答服务:手写完整五步流水线。
* 前四步(加载/切分/嵌入/入库)在 @PostConstruct 里跑一次,第五步(检索增强生成)每次提问跑。
*/
@Service
public class RagService {
private final ChatModel chatModel; // 说话模型:DeepSeek
private final EmbeddingModel embeddingModel; // 指纹机:本地 MiniLM
private InMemoryEmbeddingStore<TextSegment> embeddingStore; // 内存向量库
private static final int CHUNK_SIZE = 300; // 每块约 300 token
private static final int CHUNK_OVERLAP = 30; // 相邻块重叠 30 token
private static final int TOP_K = 3; // 取最像的 top-3
private static final double MIN_SCORE = 0.45; // 相似度门槛,低于算不相关
// 兜底话术:检索为空时不调模型直接回这句,省钱又不瞎编
private static final String FALLBACK =
"抱歉,星辰科技知识库中暂时没有这个问题的答案,建议您联系人工同事或在 OA 提单处理。";
public RagService(ChatModel chatModel, EmbeddingModel embeddingModel) {
this.chatModel = chatModel;
this.embeddingModel = embeddingModel;
}
/** 容器启动时自动建库:接口起来时知识库已就绪 */
@PostConstruct
public void loadKnowledgeBase() {
// ① 加载:读 docs/faq.md → Document(Metadata 记录出处,回答时可引用)
try {
String content = Files.readString(Path.of("docs/faq.md"), StandardCharsets.UTF_8);
Document document = Document.from(content, new Metadata().put("source", "docs/faq.md"));
// ② 切分:recursive 按标题/段落语义切,chunk=300、重叠=30
List<TextSegment> segments = DocumentSplitters.recursive(CHUNK_SIZE, CHUNK_OVERLAP).split(document);
// ③ 嵌入:批量把所有文本块算成向量
List<Embedding> embeddings = embeddingModel.embedAll(segments).content();
// ④ 入库:向量+原文成对写进内存向量库(1.21.0 没有批量 add,循环逐对写)
embeddingStore = new InMemoryEmbeddingStore<>();
for (int i = 0; i < segments.size(); i++) {
embeddingStore.add(embeddings.get(i), segments.get(i));
}
} catch (Exception e) {
throw new IllegalStateException("找不到知识库文件 docs/faq.md,请在 lesson-08 目录下启动", e);
}
}
/** 问答主流程:第五步 检索增强生成 */
public AnswerResult chat(String question) {
// 5.1 问题也向量化
Embedding qEmbedding = embeddingModel.embed(question).content();
// 5.2 相似度检索:top-3,门槛 0.45
EmbeddingSearchRequest req = EmbeddingSearchRequest.builder()
.queryEmbedding(qEmbedding).maxResults(TOP_K).minScore(MIN_SCORE).build();
EmbeddingSearchResult<TextSegment> result = embeddingStore.search(req);
List<EmbeddingMatch<TextSegment>> matches = result.matches();
List<String> references = new ArrayList<>();
for (EmbeddingMatch<TextSegment> m : matches) references.add(m.embedded().text());
// 5.3 兜底:一块都没命中 → 固定话术,不调模型
if (matches.isEmpty()) {
return new AnswerResult(question, FALLBACK, List.of());
}
// 5.4 增强生成:资料+问题拼提示词,交给 DeepSeek 照资料答(AIService8 见下)
AIService8 ai = AiServices.builder(AIService8.class).chatModel(chatModel).build();
String answer = ai.chat(Json.toJson(references), question);
return new AnswerResult(question, answer, references); // 引用片段一起返回,可追溯
}
/** 返回体:Jackson 自动转 JSON */
public record AnswerResult(String question, String answer, List<String> references) {
}
}
再看两个短文件。一个是把"只根据资料答"这条规则写进系统提示词的接口:
java
package com.langchain4j.lesson08;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
/**
* 问答接口:@SystemMessage 就是"开卷考试的考场规则"------
* 明确告诉模型:只准根据参考资料答,资料没有就明说,不许编。
*/
public interface AIService8 {
@SystemMessage("你是星辰科技的入职问答助手。请严格遵守以下规则:\n" +
"1. 只根据下面给出的【参考资料】回答用户问题,不要使用资料以外的知识;\n" +
"2. 回答要简洁直接,说清依据;\n" +
"3. 如果参考资料里没有答案,不要编造,直接回答:抱歉,知识库中暂无相关信息,请联系人工同事。\n\n")
String chat(@UserMessage("参考资料:{{ref}},用户问题:{{question}}") @V("ref") String ref,
@V("question") String question);
}
另一个是把 RAG 暴露成 HTTP 接口的 Controller,就一行:
java
package com.langchain4j.lesson08;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** GET /faq/chat?q=问题,返回 {question, answer, references[]} */
@RestController
public class FaqChatController {
private final RagService ragService;
public FaqChatController(RagService ragService) { this.ragService = ragService; }
@GetMapping("/faq/chat")
public RagService.AnswerResult chat(@RequestParam("q") String question) {
return ragService.chat(question);
}
}
盯两个 1.21.0 的真实细节(老教程已失效):建向量库是 new Metadata().put(...),不是老的 Metadata.metadata().put(...);入库要循环逐对 add(向量, 片段),批量 add(List,List) 没了。
四、实测输出:年假 0.804 命中,年会 0.765 仍拒答
以下是 2026-10-05 本机真实运行。启动日志先报了流水线进度:
text
[RAG] 第1步 加载完成:...\docs\faq.md,共 744 字符
[RAG] 第2步 切分完成:共切成 3 块(chunk=300,重叠=30)
[RAG] 第3/4步 嵌入+入库完成:向量维度=384,已入库 3 条
Tomcat started on port 8089
744 字的 FAQ 切成 3 块、每块压成 384 维向量入库。先问文档内的"年假怎么请",服务端打印的检索分数:
text
[RAG] 收到问题:年假怎么请?
[RAG] 检索命中 3 块:
相似度 0.804 | ### 2. 年假和请假怎么申请? ...
相似度 0.732 | ### 5. 星辰 CRM 怎么收费? ...
相似度 0.643 | ### 1. 公司考勤制度是什么? ...
[RAG] 模型回答:年假通过 OA 系统提交申请,需提前 3 个工作日。入职满 1 年享 5 天年假...
top-1 相似度 0.804,命中的正是"年假"那条,模型答案一个字没编,全出自资料。
再看最值钱的一段------问知识库外的"年会什么时候开":
text
[RAG] 收到问题:年会什么时候开?
[RAG] 检索命中 3 块:
相似度 0.765 | ### 2. 年假和请假怎么申请? ...
相似度 0.719 | ### 5. 星辰 CRM 怎么收费? ...
相似度 0.631 | ### 1. 公司考勤制度是什么? ...
[RAG] 已把 3 块资料塞进提示词,调用 DeepSeek 生成回答...
[RAG] 模型回答:抱歉,知识库中暂无相关信息,请联系人工同事。
仔细看:"年会"知识库里根本没有,可它的 top-1 相似度也有 0.765 ------比门槛 0.45 高一大截,单靠门槛根本挡不住。真正拦住它瞎编的是提示词那道闸:模型读完 3 块资料,发现没有一条讲年会,就照规则说了"暂无相关信息",而不是编一个"年会 12 月开"。
看出这背后的坑了吗?all-MiniLM 是偏英文的模型,中文相似度整体偏高,小知识库里"年假"0.80、"年会"0.77 拉不开差距。所以别把相似度阈值拍脑袋定死------阈值要拿你真实的问题集跑一遍再调。生产中文场景,要么换中文专用嵌入模型,要么再加一道"答案是否忠实于资料"的校验。
五、挑战题:改一改,看看会怎样
- ⭐⭐ 调相似度门槛 :把
MIN_SCORE从 0.45 改成 0.85,重问"年假怎么请"和"年会什么时候开"------观察年假的 0.804 还能不能命中、年会那 0.765 会不会被门槛直接挡掉走兜底。答案就在RagService那个常量里,跑出来你就懂"阈值拍脑袋"有多危险。 - ⭐⭐ 调切分粒度 :把
CHUNK_SIZE从 300 改成 100 重跑,看启动日志切成了几块、"年假"问题的 top-1 还是不是它。体会切太碎会把一条 FAQ 切断。 - ⭐⭐⭐ 加一条 FAQ 验证知识更新 :往
docs/faq.md里加一条"团建多久一次",重启服务问"团建",看它能不能答出来------重点体会:知识库更新 = 改文档重启,一行模型代码都不用动,更不用重训。
六、生产环境进阶:三个加分项
1. 向量库别拿内存版硬撑。 InMemoryEmbeddingStore 重启即失,教学够用。生产换 Redis-Stack、PostgreSQL-pgvector 或 Milvus------接口还是那几个 add/search,换实现不改业务代码。
2. 检索质量决定回答上限。 检索都没捞到对的片段,再强的模型也答不对------这就是 GIGO(垃圾进,垃圾出)。chunk 大小、top-k、minScore 这三个参数,都得拿你自己的真实问题集调,别照抄本例的 300/3/0.45。
3. 中文场景换中文嵌入模型。 all-MiniLM 偏英文,中文相似度挤在一起分不开。换 bge-small-zh 这类专用模型,top-1 和噪声能拉开明显差距,阈值才好定。
七、面试回答模板
面试官:RAG 的完整流程分几步?每步在干什么?
一句话:加载→切分→嵌入→入库→检索增强生成,本质是给模型开卷考试。展开:读文档成 Document,按 chunk=300/overlap=30 切成小块,每块算成向量存进向量库;提问时问题也向量化,找最像的 top-3 拼进提示词,让模型照着资料答。引用片段一起返回,回答可追溯。(指向本课第一节 + lesson-08 的
RagService.loadKnowledgeBase)
追问:RAG 靠什么减少幻觉?知识库外的问题怎么兜底?三道闸:低温 0.3 + 提示词强约束"只根据资料答" + 相似度门槛兜底。本课实测最值钱:问"年会什么时候开"(库里没有),top-1 相似度也有 0.765,门槛挡不住,真正拦住瞎编的是模型照提示词自我判断后拒答。生产建议换中文嵌入模型或加忠实度校验。(指向本课第四节实测输出)
追问:为什么文档要切分?chunk 大小和重叠怎么定?两个原因:上下文窗口装不下整篇 + 检索粒度越细回答越准。chunk 经验 200~500 token,太大混话题不准、太小切断 FAQ;overlap 约 10%,防止一句话从中间被撕成两半。本例 300/30,要拿真实文档试。(指向本课第一节)
八、总结表
| 坑 | 现象 | 解法 |
|---|---|---|
| Metadata.metadata() 无参静态方法不存在 | 老教程照抄编译报错 | 1.21.0 用 new Metadata().put("k","v") |
| 批量 add(List,List) 没了 | 编译失败 | 循环逐对 add(embedding, segment) |
| 中文 JSON 乱码 | PS 打印问号 | curl.exe -o out.json 落盘再 -Encoding UTF8 读 |
| 找不到 docs/faq.md | 启动抛异常 | 必须在 lesson-08 目录下启动 jar |
| 相似度阈值拍脑袋 | 该拒答的没拒答 | 阈值拿真实问题集跑一遍再调,别照抄 0.45 |
| 首次启动像卡死 | 半天 Tomcat 不起 | 在下载 90MB ONNX 模型,等它下完会缓存 |
九、关于这个系列
本文是「Java 后端实战精通营 」系列第 8 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。
👉 LangChain4j 实战精通营(10 课):https://gitee.com/j67mk2/langchain4j-journey
- 本文对应源码位置 :
lesson-08/(REST 工程,端口 8089,内含手写五步的RagService+AIService8提示词接口 +FaqChatController,知识库内置docs/faq.md)
系列文章一览(按发布顺序):
| 篇 | 主题 |
|---|---|
| 1 | LangChain4j 第一个对话程序:手写 LLM 调用链路,看清模型怎么"开口" |
| 2 | LangChain4j 提示词工程:模板 + few-shot + 温度,提示词从写死到复用 |
| 3 | LangChain4j 结构化输出:让模型吐出 Java 对象,JSON 不再手写解析 |
| 4 | LangChain4j 对话记忆:让模型记住聊过什么,三种方式实测对比 |
| 5 | LangChain4j 流式输出:打字机效果 + SSE,回答不再干等三秒 |
| 6 | LangChain4j 工具调用:Function Calling 让模型动手查数据,多工具连调实测 |
| 7 | LangChain4j 向量检索:文本怎么变成可搜索的坐标 |
| 8 | LangChain4j RAG 流水线:手写问答全流程,给 AI 开卷考试 |
| 9 | LangChain4j Agent:多步推理自主调用,放手让"实习生"自己安排工作 |
| 10 | LangChain4j 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,验收 5 项全过 |
下一篇预告:《LangChain4j Agent:多步推理自主调用,放手让"实习生"自己安排工作》------今天的 RAG 是"你问一句、它查一次资料答一句"。下一课把查订单、算总价的工具交给模型,你只说一句"订单 1001 和 1002 总金额多少",它会自己决定先查谁、再查谁、然后算出来。
跑完有任何报错,把终端输出发评论区,一起排查。
标签建议 :LangChain4j、RAG、向量检索
摘要建议(≤256 字):直接问大模型"年假怎么请",它凭记忆编个"提前一周、10 天",和公司手册对不上。本文手写完整 RAG 五步流水线:加载 faq.md→切分→本地向量化→入库→检索 top-3 拼提示词让 DeepSeek 照资料答。实测年假问题 top-1 相似度 0.804 命中;更值钱的是问库外"年会什么时候开",相似度 0.765 仍被提示词挡住拒答。附调阈值、调切分、加 FAQ 等挑战题与面试模板,源码在 gitee lesson-08 可 clone 直接跑。