ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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。

# 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 编码:

# 文档内问题:年假怎么请(应照资料答 + references 非空)curl.exe-o out.json"http://localhost:8089/faq/chat?q=$([uri]::EscapeDataString('年假怎么请?'))"Get-Contentout.json-Encoding UTF8# 文档外问题:年会什么时候开(应拒答)curl.exe-o out2.json"http://localhost:8089/faq/chat?q=$([uri]::EscapeDataString('年会什么时候开?'))"Get-Contentout2.json-Encoding UTF8

首次启动会下载 ONNX 嵌入模型约 90MB,耐心等一次,之后走本地缓存。PowerShell 直接打印 JSON 中文会乱码,所以用curl.exe -o落盘再按 UTF-8 读。

三、关键代码:手写五步,不套黑盒

本课核心就是RagService——我没套 EasyRAG 那种黑盒 starter,就是为了让你看清每一步。完整贴出来:

packagecom.langchain4j.lesson08;importdev.langchain4j.data.document.Document;importdev.langchain4j.data.document.Metadata;importdev.langchain4j.data.document.splitter.DocumentSplitters;importdev.langchain4j.data.embedding.Embedding;importdev.langchain4j.data.segment.TextSegment;importdev.langchain4j.internal.Json;importdev.langchain4j.model.chat.ChatModel;importdev.langchain4j.model.embedding.EmbeddingModel;importdev.langchain4j.service.AiServices;importdev.langchain4j.store.embedding.EmbeddingMatch;importdev.langchain4j.store.embedding.EmbeddingSearchRequest;importdev.langchain4j.store.embedding.EmbeddingSearchResult;importdev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore;importjakarta.annotation.PostConstruct;importorg.springframework.stereotype.Service;importjava.nio.charset.StandardCharsets;importjava.nio.file.Files;importjava.nio.file.Path;importjava.util.ArrayList;importjava.util.List;/** * RAG 问答服务:手写完整五步流水线。 * 前四步(加载/切分/嵌入/入库)在 @PostConstruct 里跑一次,第五步(检索增强生成)每次提问跑。 */@ServicepublicclassRagService{privatefinalChatModelchatModel;// 说话模型:DeepSeekprivatefinalEmbeddingModelembeddingModel;// 指纹机:本地 MiniLMprivateInMemoryEmbeddingStore<TextSegment>embeddingStore;// 内存向量库privatestaticfinalintCHUNK_SIZE=300;// 每块约 300 tokenprivatestaticfinalintCHUNK_OVERLAP=30;// 相邻块重叠 30 tokenprivatestaticfinalintTOP_K=3;// 取最像的 top-3privatestaticfinaldoubleMIN_SCORE=0.45;// 相似度门槛,低于算不相关// 兜底话术:检索为空时不调模型直接回这句,省钱又不瞎编privatestaticfinalStringFALLBACK="抱歉,星辰科技知识库中暂时没有这个问题的答案,建议您联系人工同事或在 OA 提单处理。";publicRagService(ChatModelchatModel,EmbeddingModelembeddingModel){this.chatModel=chatModel;this.embeddingModel=embeddingModel;}/** 容器启动时自动建库:接口起来时知识库已就绪 */@PostConstructpublicvoidloadKnowledgeBase(){// ① 加载:读 docs/faq.md → Document(Metadata 记录出处,回答时可引用)try{Stringcontent=Files.readString(Path.of("docs/faq.md"),StandardCharsets.UTF_8);Documentdocument=Document.from(content,newMetadata().put("source","docs/faq.md"));// ② 切分:recursive 按标题/段落语义切,chunk=300、重叠=30List<TextSegment>segments=DocumentSplitters.recursive(CHUNK_SIZE,CHUNK_OVERLAP).split(document);// ③ 嵌入:批量把所有文本块算成向量List<Embedding>embeddings=embeddingModel.embedAll(segments).content();// ④ 入库:向量+原文成对写进内存向量库(1.21.0 没有批量 add,循环逐对写)embeddingStore=newInMemoryEmbeddingStore<>();for(inti=0;i<segments.size();i++){embeddingStore.add(embeddings.get(i),segments.get(i));}}catch(Exceptione){thrownewIllegalStateException("找不到知识库文件 docs/faq.md,请在 lesson-08 目录下启动",e);}}/** 问答主流程:第五步 检索增强生成 */publicAnswerResultchat(Stringquestion){// 5.1 问题也向量化EmbeddingqEmbedding=embeddingModel.embed(question).content();// 5.2 相似度检索:top-3,门槛 0.45EmbeddingSearchRequestreq=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=newArrayList<>();for(EmbeddingMatch<TextSegment>m:matches)references.add(m.embedded().text());// 5.3 兜底:一块都没命中 → 固定话术,不调模型if(matches.isEmpty()){returnnewAnswerResult(question,FALLBACK,List.of());}// 5.4 增强生成:资料+问题拼提示词,交给 DeepSeek 照资料答(AIService8 见下)AIService8ai=AiServices.builder(AIService8.class).chatModel(chatModel).build();Stringanswer=ai.chat(Json.toJson(references),question);returnnewAnswerResult(question,answer,references);// 引用片段一起返回,可追溯}/** 返回体:Jackson 自动转 JSON */publicrecordAnswerResult(Stringquestion,Stringanswer,List<String>references){}}

再看两个短文件。一个是把"只根据资料答"这条规则写进系统提示词的接口:

packagecom.langchain4j.lesson08;importdev.langchain4j.service.SystemMessage;importdev.langchain4j.service.UserMessage;importdev.langchain4j.service.V;/** * 问答接口:@SystemMessage 就是"开卷考试的考场规则"—— * 明确告诉模型:只准根据参考资料答,资料没有就明说,不许编。 */publicinterfaceAIService8{@SystemMessage("你是星辰科技的入职问答助手。请严格遵守以下规则:\n"+"1. 只根据下面给出的【参考资料】回答用户问题,不要使用资料以外的知识;\n"+"2. 回答要简洁直接,说清依据;\n"+"3. 如果参考资料里没有答案,不要编造,直接回答:抱歉,知识库中暂无相关信息,请联系人工同事。\n\n")Stringchat(@UserMessage("参考资料:{{ref}},用户问题:{{question}}")@V("ref")Stringref,@V("question")Stringquestion);}

另一个是把 RAG 暴露成 HTTP 接口的 Controller,就一行:

packagecom.langchain4j.lesson08;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.RequestParam;importorg.springframework.web.bind.annotation.RestController;/** GET /faq/chat?q=问题,返回 {question, answer, references[]} */@RestControllerpublicclassFaqChatController{privatefinalRagServiceragService;publicFaqChatController(RagServiceragService){this.ragService=ragService;}@GetMapping("/faq/chat")publicRagService.AnswerResultchat(@RequestParam("q")Stringquestion){returnragService.chat(question);}}

盯两个 1.21.0 的真实细节(老教程已失效):建向量库是new Metadata().put(...),不是老的Metadata.metadata().put(...);入库要循环逐对add(向量, 片段),批量add(List,List)没了。

四、实测输出:年假 0.804 命中,年会 0.765 仍拒答

以下是 2026-10-05 本机真实运行。启动日志先报了流水线进度:

[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 维向量入库。先问文档内的"年假怎么请",服务端打印的检索分数:

[RAG] 收到问题:年假怎么请? [RAG] 检索命中 3 块: 相似度 0.804 | ### 2. 年假和请假怎么申请? ... 相似度 0.732 | ### 5. 星辰 CRM 怎么收费? ... 相似度 0.643 | ### 1. 公司考勤制度是什么? ... [RAG] 模型回答:年假通过 OA 系统提交申请,需提前 3 个工作日。入职满 1 年享 5 天年假...

top-1 相似度 0.804,命中的正是"年假"那条,模型答案一个字没编,全出自资料。

再看最值钱的一段——问知识库外的"年会什么时候开":

[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)

系列文章一览(按发布顺序):

篇主题
1LangChain4j 第一个对话程序:手写 LLM 调用链路,看清模型怎么"开口"
2LangChain4j 提示词工程:模板 + few-shot + 温度,提示词从写死到复用
3LangChain4j 结构化输出:让模型吐出 Java 对象,JSON 不再手写解析
4LangChain4j 对话记忆:让模型记住聊过什么,三种方式实测对比
5LangChain4j 流式输出:打字机效果 + SSE,回答不再干等三秒
6LangChain4j 工具调用:Function Calling 让模型动手查数据,多工具连调实测
7LangChain4j 向量检索:文本怎么变成可搜索的坐标
8LangChain4j RAG 流水线:手写问答全流程,给 AI 开卷考试
9LangChain4j Agent:多步推理自主调用,放手让"实习生"自己安排工作
10LangChain4j 企业智能客服: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 直接跑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进