☰
LangChain4j + SpringBoot:构建生产级RAG知识库问答全流程
2026/10/21 5:36:46 网站建设 项目流程

1. 先想清楚一件事:模型答不上来,不是模型不行

大概每个接过LLM应用开发的人都有过这种经历:把几十页内部资料丢给模型,问它其中一个细节,它要么客客气气说"我无法访问该信息",要么一本正经开始编,编得还挺像那么回事。之前我在一个内部知识问答项目里就被这个问题搞得很狼狈,后来才发现,问题根本不在模型参数多大、提示词写得多花哨,而在于我走错了路。

1.1 大模型的知识边界到底长什么样

大模型的通用知识,在训练完成那一刻就固化了。之后世界上发生了什么新事,它不知道;你公司内部的手册、合同、工单、私有Wiki,训练语料里压根没有。日常闲聊感觉不出来,一旦落到企业内部场景,立刻暴露三个短板:

  • 知识截止。新版本功能、刚发布的接口规范、最新的政策条款,模型一律答不上来。
  • 幻觉。它不会说"我不知道",而是用最合理的措辞把空白填上,专业说法叫hallucination。
  • 私有数据不可见。你文档里写了什么,对它是一堵墙。

RAG(Retrieval-Augmented Generation,检索增强生成)的思路说穿了一文不值:别让模型硬背资料,在它回答之前,先从一个外部知识库里检索出与问题相关的片段,把这些片段连同问题一起塞给它,让它"看着资料回答"。相当于给模型配了一个随叫随到的资料员,资料员负责翻书,模型负责照着念和归纳。

1.2 RAG和微调,根本不是二选一的对立面

很多人一听"要让模型懂我的文档",第一反应是微调。我的回答通常是:先搞清楚你要解决的是"知识缺失"还是"行为风格"问题。

对比维度RAG微调
知识更新速度换文档即可,分钟级生效需要重新准备语料并训练,小时甚至天级
回答可溯源能引用原文片段,便于审计不可解释,答错了你都不知道依据在哪
私有文档支持天然支持,不用标注需要整理成问答对,成本高
成本主要是向量化接口费用和存储训练算力,通常贵一个数量级
擅长场景知识问答、资料查询输出风格、语气、固定格式的调整

结论很直白:如果你的诉求是"让模型知道我司文档里写了什么",RAG是成本最低、见效最快的路径。微调更适合让模型学会某种表达习惯,或者把专业术语固化到生成风格里,而不是用来"背资料"。

1.3 LangChain4j在这个链路里的位置

Java生态里做RAG,LangChain4j是绕不开的一套东西。它把LLM应用开发里的通用环节抽象成了组件:ChatLanguageModel(对话模型)、EmbeddingModel(向量化模型)、EmbeddingStore(向量存储)、DocumentSplitter(文档切分器)、ContentRetriever(内容检索器),再用AiServices把这些组件编排成完整的问答服务。

配合SpringBoot的starter自动装配,你不需要手动new一堆对象,配置写在application.yml里,注入即用。这一篇我按一条完整的RAG流水线往下拆:从工程配置讲到文档切分、向量化、检索,再到排查链路和进阶调优。每一步都会讲清楚"为什么这么做"和"这里容易踩什么坑"。

2. 地基:SpringBoot工程里怎么引入、怎么配置

2.1 starter到底替我们做了多少事

先看最简单的Maven引入,以当前稳定版本为准:

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai-spring-boot-starter</artifactId> <version>0.36.2</version> </dependency>

版本号请以你拉取时的最新稳定版为准,我写这篇时用的还是0.36.x。这个starter一进来,Spring容器里就自动出现了两样关键东西:ChatLanguageModel和EmbeddingModel。你没写任何一行创建对象的代码,它们就已经能注入Service了。

这就是starter的价值:把"读配置、构建客户端、设置超时重试"这类模板代码全部吃掉了。你只需要关心业务代码。不过自动配置有个副作用——如果你在配置里漏了api-key,应用启动时不一定报错,但一调用就抛异常,而且异常信息往往不够直观。所以我建议一上来就打开Spring Boot的debug日志,或者直接看一眼自动配置报告,确认这两个Bean真的创建成功了。

2.2 配置项里最容易翻车的几个点

application.yml里最基础的配置长这样:

langchain4j: open-ai: chat-model: api-key: ${LLM_API_KEY} model-name: gpt-4o-mini temperature: 0.2 timeout: 60s max-retries: 2 embedding-model: api-key: ${LLM_API_KEY} model-name: text-embedding-3-small

三个地方特别容易翻车:

第一,对话模型和Embedding模型是两套独立配置。很多人只配了chat-model,结果注入EmbeddingModel时拿到一个直接用默认值的对象,或者干脆启动失败。你在RAG里既要对话又要向量化,两段都要写。

第二,temperature建议压低。RAG场景我们希望模型忠实于检索到的资料,temperature设成0.2左右比较合适。设太高,模型会放飞自我,把资料扔一边自己发挥。这类问题特别隐蔽,因为它不是报错,只是回答质量飘忽。

第三,超时和重试别用默认值裸奔。Embedding模型处理长文本时比较慢,默认超时可能不够;而LLM接口的偶发超时也很常见。timeout设成60秒、max-retries设成2,是我试下来比较稳的组合。

2.3 没有大模型Key的时候,怎么在本地先跑通

很多人卡在第一步:没有可用的API Key,怎么验证整条链路?两个思路。

一是用本地模型服务。LangChain4j有对应的starter,把大模型服务跑在本机,配置里指向localhost即可。Embedding也有本地方案,专门有基于ONNX的本地嵌入模型模块,加载即用、完全离线。缺点是对机器性能有要求,嵌入模型倒是还好,对话模型如果比较大会吃内存。

二是先用假配置把链路跑通,验证"检索"这一步。RAG里真正核心的是检索质量,Embedding可以用本地小模型顶上,对话部分临时用简单模型,等拿到Key再切换。这条路径我建议所有人都走一遍,因为后面调优时你会频繁需要"只看检索结果、不看最终回答"的调试手段。

3. RAG流水线的四个环节逐一拆解

RAG不是一个大而全的魔法,而是四个环节串起来的一条流水线:文档加载与切分、向量化、存储、检索。任何一个环节出了问题,最终回答都会不对劲。

3.1 文档加载与切分:所有下游问题的源头

加载这一步很简单,LangChain4j的DocumentLoader给了现成的方法:

// 加载本地文件 Document document = FileSystemDocumentLoader.loadDocument(Paths.get("docs/产品手册.txt")); // 也支持加载URL文档 Document urlDoc = UrlDocumentLoader.load(URL.create("https://example.com/help.html"));

txt、md、html都好说,PDF需要额外的解析器依赖,解析效果取决于文档本身的质量。图片类PDF基本别指望纯文本加载能靠谱。

真正决定成败的是切分。你不可能把整本手册一次性塞给模型,上下文窗口放不下,而且检索粒度太粗,命中率极低。所以要切成一段一段的文本片段,专业叫TextSegment。

DocumentSplitter splitter = DocumentSplitters.recursive(300, 50); List<TextSegment> segments = splitter.split(document);

第一个参数是每段的目标长度(按token数算),第二个参数是相邻段落之间的重叠长度。为什么要重叠?因为文档在切分处可能把一个完整语义拦腰截断。上一段的结尾和下一段的开头本来是一句连贯的话,或者一个表格的上下文,重叠部分能让两边都保留足够的线索。

切分参数直接决定后面所有环节的上限。段太长,一段里揉进去太多主题,向量化之后语义被平均掉,检索时哪哪都沾一点、哪哪都不精准;段太短,单段上下文不完整,模型拿到片段也读不懂。我见过太多人把时间花在调Prompt上,最后发现问题出在切分参数上。

3.2 向量化:Embedding模型的选型逻辑

切好的文本片段,要变成计算机能算"相似度"的东西,这就是Embedding模型干的活。它把一段文本映射成一个高维向量,语义相近的文本,向量在空间里也靠近。

EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder() .apiKey(System.getenv("LLM_API_KEY")) .modelName("text-embedding-3-small") .build(); List<Embedding> embeddings = embeddingModel.embedAll(segments).content();

选Embedding模型有三条经验:

一是中文场景优先选多语言或中文专项模型。纯英文优化的模型处理中文,向量质量会打折扣,检索召回率肉眼可见地下降。

二是入库和检索必须用同一个Embedding模型。这不是建议,是硬性要求。不同模型生成的向量在不同的向量空间里,就像两套坐标系,算出来的相似度没有任何意义。很多人入库时用一个模型,检索时换了另一个,然后百思不得其解为什么召回结果一塌糊涂。

三是维度不是越高越好。高维度向量确实能表达更细的语义,但存储和计算成本也上去了。业务场景里中小规模知识库,几百维的模型完全够用。

3.3 存储:从进程内Store到专业向量库

向量存哪,取决于你的知识库规模。LangChain4j最基础的是InMemoryEmbeddingStore,全部存在内存里,适合验证链路、做Demo。

EmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>(); embeddingStore.addAll(embeddings, segments);

它有个致命短板:不持久化。应用一重启,向量全部消失,你得重新加载文档、重新切分、重新向量化。我见过有人部署上线第二天发现问答突然全部失效,就是因为它以为数据存住了。

InMemoryStore提供了序列化方法,可以手动持久化到文件再加载。但对稍微正式一点的项目,我更推荐直接上专业向量库:Milvus、Qdrant、Chroma、pgvector这些都是LangChain4j官方支持过的方向,各有对应集成包。

存储方案部署方式持久化适合规模备注
InMemoryEmbeddingStore进程内手动序列化几千段以内适合Demo和调试
Chroma独立服务或嵌入式自动中小规模上手快
pgvector借助PostgreSQL自动中小规模复用现有数据库
Milvus / Qdrant独立服务自动大规模支持混合检索、生产首选

3.4 检索与组装:ContentRetriever到底怎么工作

检索环节LangChain4j封装成了ContentRetriever,最常用的是EmbeddingStoreContentRetriever:

ContentRetriever retriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(3) .minScore(0.7) .build();

它的工作流程是:用户问题先被Embedding模型向量化,然后在向量库里做相似度搜索,取相似度最高的前N个候选,再按minScore过滤掉相似度太低的噪声,最后把留下的文本片段注入Prompt,交给对话模型。

maxResults控制"最多取几段"。取太少可能漏掉关键信息,取太多会把不相关的东西也塞进上下文,既稀释注意力又浪费token。minScore是相似度阈值,这个值需要根据你实际数据的分数分布来定,后面我会专门讲怎么调。

到这里,RAG的四个环节就串起来了。下面我把它们拼成一个能跑的SpringBoot服务。

4. 跑通最小闭环:一个SpringBoot Demo的全过程

4.1 代码走读:三段代码,一条链路

第一段,文档入库。用一个Spring组件,启动时自动加载文档、切分、向量化、写入存储:

@Component public class DocumentIngestor { private final EmbeddingModel embeddingModel; private final EmbeddingStore<TextSegment> embeddingStore; public DocumentIngestor(EmbeddingModel embeddingModel, EmbeddingStore<TextSegment> embeddingStore) { this.embeddingModel = embeddingModel; this.embeddingStore = embeddingStore; } @PostConstruct public void load() { Document document = FileSystemDocumentLoader .loadDocument(Paths.get("docs/产品手册.txt")); DocumentSplitter splitter = DocumentSplitters.recursive(300, 50); List<TextSegment> segments = splitter.split(document); List<Embedding> embeddings = embeddingModel.embedAll(segments).content(); embeddingStore.addAll(embeddings, segments); } }

注意这里直接注入EmbeddingStore ,靠starter的自动配置就能拿到。如果没配默认的store,你得自己定义一个Bean。

第二段,定义问答助手接口并组装。LangChain4j的AiServices是核心编排器:

interface RagAssistant { String chat(String userMessage); } @Component public class RagSearcher { private final RagAssistant assistant; public RagSearcher(ChatLanguageModel chatModel, EmbeddingModel embeddingModel, EmbeddingStore<TextSegment> embeddingStore) { ContentRetriever retriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(3) .minScore(0.6) .build(); this.assistant = AiServices.builder(RagAssistant.class) .chatLanguageModel(chatModel) .contentRetriever(retriever) .build(); } public String ask(String question) { return assistant.chat(question); } }

第三段,暴露一个REST接口:

@RestController public class RagController { private final RagSearcher ragSearcher; public RagController(RagSearcher ragSearcher) { this.ragSearcher = ragSearcher; } @GetMapping("/ask") public String ask(@RequestParam String question) { return ragSearcher.ask(question); } }

4.2 实测效果和参数微调

跑起来之后,随便问几个问题。我当时拿的是一份产品手册,问"产品支持导出哪些格式",回答能把格式列表和适用条件说明白,而且跟原文对得上;问"售后响应时间是多久",也能给出准确数字。这就是RAG该有的效果——模型没有在编,它在复述资料。

但参数一定得微调。第一次跑通时我把minScore设成0.6,回答质量时好时坏。后来我在调试模式里把每次检索的相似度分数打出来看,发现命中的片段分数通常在0.65到0.78之间,而真正不相关的片段一般低于0.55。所以阈值卡在0.6到0.65之间最合适。这个分数分布跟你的文档类型、Embedding模型强相关,别照抄别人的值,一定要基于自己数据的实测。

4.3 跑通之后立刻要处理的三个隐患

Demo能跑和能上线是两码事。我列出三个最常见的隐患:

一是重启丢数据。前面说过InMemoryEmbeddingStore不持久化。解决方案要么每次启动重新入库,要么序列化到文件。更稳妥的做法是直接把向量库换成独立服务。

二是文档一更新,旧向量和新向量混在一起。如果产品手册改了版本,旧内容没有清理,检索就可能命中过时信息。生产环境要考虑版本管理,起码在元数据里带上文档版本号,检索时过滤掉旧版本。

三是上下文超窗。maxResults设成5、每段又长,加上对话历史,一不小心就超出模型的上下文窗口。报错看起来像"请求过大",实际是检索结果+历史消息撑爆了。控制maxResults和segmentSize要从一开始就想好。

5. 检索不精准的完整排查链路

如果说前面是"怎么搭",这一节是"搭好之后出了问题怎么查"。RAG效果不好的时候,90%的人都直接调Prompt,这是最低效的路径。正确顺序是:先查检索、再查切分、最后才查生成。

5.1 召回了一堆不相关内容,先别急着骂模型

症状:模型回答里出现了资料里根本没有的信息,或者答非所问。很多人第一反应是模型在幻觉,其实大概率是检索环节把不相关的片段塞进了Prompt。模型只是忠实地基于你给的垃圾资料在回答。

排查第一步,绕过对话模型,直接调ContentRetriever看结果:

// 调试用:直接检索,看看返回了什么 ContentRetriever retriever = ...; List<Content> contents = retriever.retrieve(Query.from("产品支持导出哪些格式")); contents.forEach(c -> System.out.println(c.textSegment().text())); System.out.println(c.textSegment().metadata());

这样能看到模型"看到了什么"。如果检索结果本身就文不对题,那问题一定在检索链路里。按这个顺序查:

  1. Embedding模型是否一致。入库和检索用的是不是同一个?这是头号原因。
  2. 切分质量。打印几个片段,看看有没有被拦腰截断、语义不完整的段落。如果切分乱了,检索再怎么调都白搭。
  3. minScore阈值。把阈值降下来看返回的候选分数分布,再决定卡多少合适。
  4. 文档加载是否乱码。特别是中文文件,编码不对会导致整个向量全是噪声。

5.2 中文场景特有的切分与编码问题

中文跟英文在切分上的表现差异很大。英文按空格和标点切就很自然,中文的递归切分主要靠标点符号,如果文档里长句很多、逗号少,切出来的片段就可能超出目标长度,或者语义被硬切。

另外一个更隐蔽的坑是编码。用FileSystemDocumentLoader加载文档时,默认编码可能不是UTF-8。Windows下常见的GBK编码文件,加载进来就是一堆乱码,向量化之后每个片段都变成了无意义的向量。检查方法很简单:打印前100个字符看看。这个问题我们当时排查了很久,最后发现是某份旧文档用了GBK编码,其他文档都是UTF-8,导致只有那一份文档的召回结果惨不忍睹。

处理办法是在加载时显式指定编码:

Document document = FileSystemDocumentLoader.loadDocument( Paths.get("docs/产品手册.txt"), DocumentSource.from(path -> { try { return Files.readString(path, StandardCharsets.UTF_8); } catch (IOException e) { throw new RuntimeException(e); } }));

实际项目中我建议把所有源文档统一转成UTF-8存储,不要指望代码兼容各种编码,那会让问题排查复杂十倍。

5.3 持久化、并发与扩展性隐患

InMemoryEmbeddingStore在内存里做暴力搜索,几千个片段没问题,但文档一多,每次检索都要全量扫描计算相似度,响应时间会明显上涨。更麻烦的是并发写——如果一边在服务查询,一边又在入库,数据的一致性没人给你保证。

我的建议是:入库和查询分离。入库做成离线的独立任务,生成好的向量存储(或序列化文件)在应用启动时加载。查询服务只读,不写入。这是最稳的架构,也方便后续把存储换成专业向量库。

6. 把RAG从"能跑"调到"好用"的进阶操作

6.1 混合检索与重排序

纯向量检索有一个知名短板:精确关键词匹配能力弱。比如用户问"型号ABC-123的保修期",如果这个型号在文档里以特殊格式出现,向量相似度可能抓不住它,而普通关键字搜索一抓一个准。

所以生产级RAG普遍采用混合检索:向量检索抓语义,关键词检索抓精确匹配,两者结果合并去重,再排序。LangChain4j的自定义Retriever就是干这个的:

public class HybridRetriever implements ContentRetriever { private final ContentRetriever vectorRetriever; private final ContentRetriever keywordRetriever; @Override public List<Content> retrieve(Query query) { List<Content> results = new ArrayList<>(); results.addAll(vectorRetriever.retrieve(query)); results.addAll(keywordRetriever.retrieve(query)); // 去重、合并排序 return deduplicateAndSort(results, query.text()); } }

在此基础上还可以加重排序(Rerank)。先宽松地取回10个候选,用一个跨编码器的重排序模型重新打分,保留最相关的3个。重排序模型比Embedding模型更精细,代价是慢,所以只对候选集跑。这一步对最终回答质量的提升非常明显,也是很多"看起来差不多但回答总差口气"问题的解药。

6.2 元数据过滤:让检索范围可控

知识库一复杂,你会希望检索不是全局搜,而是限定在某个范围内。LangChain4j的TextSegment支持Metadata,入库的时候带上来源、分类、版本、部门等字段:

TextSegment segment = TextSegment.from(text, Metadata.from(mapOf( "source", "产品手册-2024版", "category", "售后政策", "tenantId", "org_001" )));

检索时按元数据过滤,可以实现两类非常有用的能力:

一是多租户隔离。每个租户只能检索到自己的文档片段,这是企业级应用的基本要求,否则跨租户数据泄漏就是大事故。

二是定向检索。用户问"售后政策怎么样",可以先识别意图,再限定category等于"售后政策"的片段去检索,精度提升立竿见影。

6.3 上下文压缩与引用溯源

最后说两个关于Prompt侧的经验。一是别把检索结果一股脑全塞进去。研究发现,大模型对长Prompt中间部分的内容注意力会下降,被称作"lost in the middle"。检索回来的5个片段,中间那两个常常被模型忽略。解法是控制maxResults在3到5之间,配合重排序把最重要的放最前。

二是引用溯源。企业问答里"这话是哪份文档说的"比回答本身还重要。做法是把来源写进Metadata,并让模型在回答时引用来源编号。LangChain4j的AiServices可以通过配置让模型在回答里带上引用。这样出了问题可以回溯,老板和合规部门都放心。

我在实际项目里的体会是:RAG调优没有银弹,任何参数都要基于你自己的数据实测。但有一条方法论是通用的——每次改动只动一个变量,并且用同一组黄金测试题去回归。我维护了一组50题左右的标准问题集,每次调整切分参数、阈值或者检索策略,都拿这组题跑一遍,对比回答质量。这比任何"别人说好的参数"都可靠得多。

如果你顺着这条链路搭完,大概率会发现,真正影响问答质量的因素排序是:文档切分质量大于Embedding模型选型大于检索阈值大于Prompt设计大于模型本身。很多刚接触RAG的人把精力都花在最后两项上,方向反了。先把前面三项打好底子,你会发现那些"模型不聪明"的问题,其实有大半根本轮不到模型来背锅。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询