LangChain4j RAG 流水线:手写问答全流程,给 AI 开卷考试

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 不是加了检索就万事大吉,本课实测发现三道闸缺一不可:

  1. 低温 :temperature=0.3,不让模型自由发挥;
  2. 提示词强约束:"只根据资料答,资料没有就明说,不许编";
  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 拉不开差距。所以别把相似度阈值拍脑袋定死------阈值要拿你真实的问题集跑一遍再调。生产中文场景,要么换中文专用嵌入模型,要么再加一道"答案是否忠实于资料"的校验。

五、挑战题:改一改,看看会怎样

  1. ⭐⭐ 调相似度门槛 :把 MIN_SCORE 从 0.45 改成 0.85,重问"年假怎么请"和"年会什么时候开"------观察年假的 0.804 还能不能命中、年会那 0.765 会不会被门槛直接挡掉走兜底。答案就在 RagService 那个常量里,跑出来你就懂"阈值拍脑袋"有多危险。
  2. ⭐⭐ 调切分粒度 :把 CHUNK_SIZE 从 300 改成 100 重跑,看启动日志切成了几块、"年假"问题的 top-1 还是不是它。体会切太碎会把一条 FAQ 切断。
  3. ⭐⭐⭐ 加一条 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 直接跑。

相关推荐
HIT_Weston1 小时前
247、【AI】【模型部署】基座模型研究:RoPE 用在前向还是反向——训练、推理与 KV cache
人工智能
m4Rk_1 小时前
【论文阅读】Agent 记忆机制(97):MCMA——让 Agent 学会如何抽象记忆,实现跨任务经验迁移
论文阅读·人工智能·学习·开源·github
晓德1 小时前
7、LLM 模型是怎么训练生成的?(4)指令微调
人工智能·学习·算法·信息安全
蝶依斓(湖南)全案整装1 小时前
整装企业环保健康交付内容写作方法:空气健康、环保材料与检测标准的非空泛表达
大数据·人工智能
问天_观心1 小时前
Mistral-7B QLoRA 实战(二):官方权重转换、4-bit NF4 加载与 Base Model 基线测试
人工智能·深度学习·学习·大模型·transformer
倔强的石头1061 小时前
Gemini架构解析_多模态大模型的设计思路
人工智能·大模型
计算机编程指导师1 小时前
【大数据毕设】基于Hadoop+Django乳腺癌数据分析与可视化系统选题推荐 源码 毕业设计 毕设选题 数据分析 机器学习
大数据·hadoop·python·计算机·毕业设计·课程设计·乳腺癌
码农小旋风1 小时前
OpenSpec:轻量级AI工程规范管理框架
人工智能
AOI小白新手上路1 小时前
深度学习项目模板与机器学习软件工程(MLSE)实践笔记
深度学习·机器学习·软件工程