☰
Java + LangChain4j 构建企业知识库问答 RAG 全流程实践指南
2026/10/20 3:13:47 网站建设 项目流程

前阵子接了一个很典型的需求:公司内部的文档散落在 Wiki、产品手册、售后工单和一堆 PDF 里,业务同事想找一个答案,要么翻群聊天记录,要么反复问研发。说白了,大家想要的是一个能直接“问”的系统。需求一出来,团队里第一反应都是 Python + LangChain,但我们基础设施基本全在 Java 体系里,再造一套 Python 服务,意味着运维、监控、权限都要多伺候一个进程。于是我把目光放到了 Java 生态,折腾一圈之后发现,LangChain4j 已经能把文档解析、向量化、检索、问答整条链路串起来,而且能在 Spring Boot 工程里直接落地。

这篇文章我不打算讲太多理论,重点是把用 Java + LangChain4j 从零搭企业知识库问答的完整过程拆给你看:依赖怎么配、文档怎么切、向量怎么存、问答链路怎么组装,以及最后上线前必须处理的权限和调优问题。适合正在 Java 技术栈里做同样事情的开发者,也适合想评估这套方案能不能在企业里落地的架构师。

1. 先把流程理清楚:为什么 Java 服务里适合放一套 RAG

1.1 企业知识库问答的本质,是先检索到对的资料再让模型回答

企业知识库问答和通用大模型问答最大的区别,是答案必须来源于企业自己的资料库,而不是大模型从训练语料里“背”出来的内容。比如售后同事问:“某个设备在高温环境下报警,应该先检查哪个模块?”这种问题大概率不在大模型的训练数据里,就算它知道类似知识,也不敢保证和我们公司的产品文档一致。RAG 的解决思路是先把问题拿去检索企业文档,找到最相关的几个片段,再把片段连同问题一起交给大模型,让模型基于这些片段生成答案。

这样做的直接好处有两个:第一,答案的范围被约束在内部资料里,而不是模型自由发挥;第二,只要资料库更新,答案就能跟着更新,不用重新训练模型。对于绝大多数企业来说,知识库问答的瓶颈从来不是“模型不够聪明”,而是“文档没有被有效地找到”。这一条想清楚之后,后面所有技术选型都会顺理成章。

1.2 为什么不额外起一套 Python 服务

团队里不少人一开始建议在 Python 侧做,理由也很直接:Python 生态里的大模型工具链最成熟,示例代码最多,遇到问题随手一搜就有答案。这个理由我承认,但放在我们的真实环境里,问题就来了。公司里已有的认证中心、权限体系、日志平台、监控告警全是围绕 Java 服务建的,如果知识库问答系统用 Python 写,就要单独接一轮认证,单独定一套日志规范,单独配置部署流水线。一套系统因为技术栈不同被隔离在主体系之外,后续维护成本远比写代码本身高。

LangChain4j 的价值就在这里。它在 Java 世界里提供了和 Python 侧相近的组件:文档加载器、文本拆分器、向量模型封装、向量存储抽象、检索增强生成的组装工具。虽然起步比 Python 侧晚,但核心能力都已经有,而且它原生适配 Spring Boot,配置、注入、监听都可以复用团队熟悉的套路。对我这种 Java 后端出身的人来说,用熟悉的框架写 RAG,比边学 Python 边写 LangChain 舒服得多。

1.3 整条链路的六步,先用一张表压住节奏

动工之前,我习惯先把整个流程画在纸上,避免写着写着迷失方向。知识库问答从文档到答案,大概可以分成六步:

步骤输入输出说明
文档加载PDF、Word、Markdown纯文本 Document把各种格式统一成文本
文本清洗解析后的文本干净的片段内容去掉页眉页脚、乱码、多余空格
切片整篇文档多个 TextSegment控制检索粒度和上下文长度
向量化每个切片Embedding 向量把文本变成数值向量
索引入库向量 + 切片向量库中的索引条目同时保存元数据备查
检索增强生成用户问题最终答案检索 TopK 片段并交给模型回答

后面所有章节,基本都是这六步的逐个展开。你也可以把这个表当作项目排期,先跑通 1 到 5 步,做一个能回答问题的 Demo,再回头优化第 6 步的体验。我最后实际做的时候,也是先花两天时间把前五步走通,才着手处理权限和调优。

2. 依赖和骨架:从头建一个带 LangChain4j 的 Spring Boot 工程

2.1 Maven 依赖到底要加哪些

新建工程并不复杂,关键是把依赖加对。我用的是普通的 Maven 工程,配上 Spring Boot 的 Web 启动器,核心依赖如下:

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>1.0.0-beta1</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-spring-boot-starter</artifactId> <version>1.0.0-beta1</version> </dependency>

如果涉及 PDF 解析,还需要单独引入对应解析器模块:

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-document-parser-apache-pdfbox</artifactId> <version>1.0.0-beta1</version> </dependency>

这里要特别提醒一句:版本号一定要和你的 Spring Boot 版本匹配。LangChain4j 的启动器更新速度不算慢,早期某些版本对 Spring Boot 2.x 和 3.x 的支持范围不一样,盲目用新版本容易出现类找不到或者方法签名不匹配的问题。我第一次搭建时就踩了这个坑,换了一套版本组合才稳定下来。建议开工前先看一眼官方仓库里的版本说明,锁定一个组合,不要顺手写 latest。

2.2 配置文件里的模型接入信息

工程建好之后,下一步是把模型服务的连接信息放进 Spring Boot 的配置文件。我这里用的是比较通用的 OpenAI 兼容协议接口,也就是说,不管是大模型服务商提供的 API,还是企业内部自己部署的推理服务,只要暴露的是兼容协议,就能直接用。对应的配置大概是这样的:

langchain4j: open-ai: base-url: http://your-llm-service:8080/v1 api-key: local-dev-key chat-model: model-name: your-chat-model temperature: 0.2 embedding-model: model-name: your-embedding-model

注意这个open-ai前缀在 LangChain4j 里只是一个约定,意思是“兼容 OpenAI 协议的模型服务”,并不代表一定要连官方的地址。内部部署的模型服务只要协议兼容,填好 base-url 和 model-name 就能跑起来。

2.3 为什么先把版本问题单独说

很多教程上来就贴代码,不聊版本,结果读者照着敲完发现编译不过。我在实际项目中感受很深:Java 生态的版本兼容问题比 Python 严苛得多,你费半天劲排查,最后发现只是某个依赖版本不对。所以这一章我宁可说得啰嗦一点。固定的组合建议是:JDK 17 以上、Spring Boot 3.x、LangChain4j 当前稳定版本,三者的搭配一般不会有太大问题。JDK 8 的老项目如果想上 LangChain4j,需要特别留意版本,不要默认支持,提前在官方的 Release 说明里确认一下再动手。

3. 文档到切片:PDF、Word、Markdown 的解析与分块实测

3.1 多格式文档解析没有想象中那么难

企业文档最常见的三种格式是 PDF、Word 和 Markdown。Markdown 本身就是纯文本,直接读文件内容就行。PDF 的情况比较复杂,有两种解析思路:一种是直接抽取文字,适合数字化的 PDF;另一种是用 PDF 渲染成图片再走 OCR,适合扫描件。我为了一开始跑通流程,先用的是文字抽取方案,依赖上章提到的 PDFBox 解析器。

用 LangChain4j 解析 PDF 的示例代码大致如下:

DocumentParser parser = new ApachePdfBoxDocumentParser(); Document document = parser.parse(inputStream);

Word 文档需要 Windows 下常见的有.docx格式,LangChain4j 本身也有对应的解析模块,原理是解析 OOXML 结构中的文本节点。这里有个实践技巧:企业内部文档真正复杂的不是格式转换,而是内容本身。比如 PDF 里的页眉页脚、表格、图片说明,往往会在解析后混进正文里,形成一串看着头疼的乱文本。我的做法是先解析出全文,再做一轮清洗规则,把明显不需要的内容去掉。

3.2 分块策略直接决定答案质量的上限,别随便定

文本解析完之后,不能整篇丢给向量模型。不管是 embedding 模型还是大模型,对输入长度都有上限,而且检索时如果切片太大,问题命中就容易被无关内容稀释。LangChain4j 提供了一系列拆分器,我实际用下来最常用的是按段落拆分和按固定长度拆分两种。

DocumentSplitter splitter = new DocumentByParagraphSplitter(200, 20); List<TextSegment> segments = splitter.split(document);

这段代码的意思是按段落切分,每个切片最多 200 个字符,切片之间保留 20 个字符的重叠。重叠的作用是防止句子被从中间截断,导致语义残缺。除了按段落,LangChain4j 还支持按句子、按单词、按固定字符长度拆分:

拆分器适用场景我的实测感受
DocumentByParagraphSplitter规范手册、制度文档语义最完整,推荐优先试
DocumentBySentenceSplitterFAQ、对话记录精确回答效果好
DocumentByWordSplitter长文本无结构文档容易把语义切碎,慎用
DocumentByCharacterSplitter特殊格式文本一般备用,中文场景不推荐

中文场景下我特别强调一点:不要按英文单词的思路去切。中文字符没有空格分词,看起来是“按字符切”,实际上很容易把一个完整的问题上下文切断。我建议优先按段落或按句子切,切片长度控制在 200 到 500 字之间。太长会让检索结果模糊,太短又会让模型缺乏上下文。

3.3 给每个切片打上“身份信息”,否则后面没法溯源

切片之后,还有个很容易被忽略但特别重要的动作:给每个切片附加元数据。所谓元数据,就是切片来源的“身份信息”,比如文档编号、文档标题、所属部门、章节路径。LangChain4j 里这样写:

TextSegment segment = TextSegment.from(content, metadata);

元数据在生产环境里有两大用处。一是权限过滤,比如售后部门的文档只能由售后同事检索到;二是引用溯源,用户问完问题之后,系统需要告诉用户“这个答案来自哪份文档的哪个章节”,如果没有元数据,根本没法做。这块我在项目一开始没重视,后来补引用功能时发现每份切片都要重新索引,返工成本很大。所以建议从搭建第一天就把元数据设计好。

4. 向量化入库:embedding 模型选型和向量库接入细节

4.1 Embedding 模型怎么选:中文场景不能拍脑袋

文本切片处理好之后,下一步是把它变成向量。embedding 模型的选择直接决定检索质量。这里要说一个容易踩的坑:很多在英文语料上表现不错的向量模型,处理中文时效果会明显下降。中文没有空格分词,同一个词在不同语境下的含义差异更大,所以专门用中文语料训练或微调的模型会更靠谱。

选择时我主要看三点:第一,中文效果是否经过验证,不能只看榜单分数;第二,向量维度是否和后续向量库匹配,维度不一致是运行时才会暴露的问题;第三,部署方式能不能接受。外部 API 方便,但企业文档如果涉及敏感信息,就必须走本地部署方案。我自己的建议是,先不管本地还是 API,只要协议兼容就可以把流程跑通,等验证了效果再根据数据隐私要求切换部署方式。

4.2 向量库接入:先用内存版跑通,再换持久化方案

LangChain4j 对向量存储做了一个统一的抽象,也就是EmbeddingStore接口。它的好处是,上层业务代码不用关心底层是内存实现、文件实现还是外部向量库,切换实现类的时候业务代码基本不动。

第一步跑通流程,我建议直接用 LangChain4j 自带的内存向量库:

EmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();

这个实现类把向量和切片全部存在 JVM 进程里,优点是零部署、零配置,适合验证数据和代码逻辑。缺点是重启即丢,而且数据量大了之后内存压力大,只适合 Demo 阶段。正式环境里,我倾向于把向量库换成支持持久化的开源向量库,或者直接复用团队已有的中间件。切换时只有一个关键点:确认向量库内部使用的索引类型和距离算法与向量模型匹配。

4.3 入库代码和数据更新逻辑

向量化入库的代码没有太多玄机,过程就是“先切分、再向量化、最后入库”。完整代码如下:

List<TextSegment> segments = splitter.split(document); List<Embedding> embeddings = embeddingModel.embedAll(segments).content(); embeddingStore.addAll(embeddings, segments);

这里有两个细节值得说。第一,embedAll是一次性批量向量化,比循环调用效率高得多,文档量大时一定要用批量接口。第二,入库时要保证EmbeddingStore的两个泛型参数匹配,一个存向量,一个存原文,检索时同时把两者取出来才有意义。

数据更新和删除是企业场景绕不开的问题。文档修订了,旧版本还留在向量库里,用户就会检索到过期内容。用内存向量库时没有原生的删除接口,我的做法是维护一个文档编号的索引表,更新时找到旧切片,从EmbeddingStore中按 id 移除或者直接重建整个索引。生产环境如果数据量很大,更现实的思路是重跑一遍增量任务,只向量化变更过的文档。

5. 组装问答链路:检索、Prompt、流式输出与引用溯源

5.1 构建检索器:不能只看 TopK,还要设最低相关性分

数据和索引都准备好了,接下来开始组装问答链路。第一步是构建检索器,也就是把用户问题转成向量后,去向量库找最相近的几个片段。

EmbeddingStoreRetriever retriever = EmbeddingStoreRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(4) .minScore(0.5) .build();

maxResults决定检索多少片段返回给大模型,minScore决定最低相关度阈值。这个阈值很关键:设高了,检索结果可能为空,模型只能承认自己不知道;设低了,大量不相关片段会混进来干扰模型。我在测试阶段会先放宽到 0.3 看召回结果,观察返回的前几名是不是真的相关,再逐步收紧到 0.5 左右。不同的向量模型和文档类型,这个值存在差异,建议做几次实验再定。

5.2 把检索结果和问题组装成一次问答会话

LangChain4j 最省心的一点是,它提供了把检索器、对话模型和记忆统一组装起来的方式。我定义了一个简单的问答接口:

interface Assistant { String answer(String userMessage); }

然后通过 LangChain4j 的构建器把组件组装到一起:

Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(chatModel) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .contentRetriever(retriever) .build();

实际调用时只需要一行:

String answer = assistant.answer("高温环境下设备报警,应该先检查哪个模块?");

这一步的底层逻辑是:系统先自动检索出相关知识片段,把这些片段连同用户问题和之前的对话历史一起放入大模型的上下文,再让模型生成回答。contentRetriever这个配置项就是 RAG 的开关,加不加上去,效果完全不一样。不加它,模型只能瞎猜;加了它,答案就有了依据。

5.3 Prompt 模板:回答要基于资料,宁可承认不知道

虽然 LangChain4j 默认的组装方式已经足够好用,但我在实际项目里还是会自己控 Prompt。原因很简单:默认 Prompt 比较通用,企业场景需要额外的规则和口吻。比如我希望模型回答不上来的时候直接说“资料库未涵盖此问题”,而不是生硬编一个答案。

PromptTemplate promptTemplate = PromptTemplate.from( "以下是知识库中检索到的资料,请仅基于资料回答用户问题。\\n" + "如果资料中没有相关内容,请回答:资料库暂未覆盖该问题。\\n\\n" + "资料:\\n{{information}}\\n\\n" + "用户问题:{{userMessage}}" );

在组装信息时,把检索到的片段拼接进information变量,把用户问题放进userMessage变量,就能生成最终发给模型的完整 Prompt。这种显式指定规则的方式,比依赖默认行为更可控,也方便后续调整回答风格。

5.4 流式输出和引用溯源

Demo 里用同步返回字符串没问题,但产品化的问答系统,尤其面对业务人员时,等待五秒钟才看到一段完整答案的体验非常糟。LangChain4j 支持流式接口,可以把回答逐字推送到前端,体验上和 ChatGPT 一样。核心思路是使用TokenStream而不是直接调用同步方法,在onPartialResponse回调中把增量文本推给前端,同时在onComplete中把引用文档信息一并返回。

引用溯源是我强烈建议第一个版本就要加的功能。企业知识库问答最大的信任危机,就是模型一本正经地给了一个错误答案。如果每个答案后面都带“来源:XX手册-第X章”,用户自己就能判断可信度。实现上并不复杂,把检索到的TextSegment的元数据拿出来,和答案一起返回即可。这一步投入产出比极高,别省。

6. 企业工程改造:权限隔离、并发缓存与答案质量调优

6.1 权限隔离:不同角色只能检索到自己的文档

Demo 跑通之后,第一件要做的事就是权限。企业知识库必然涉及部门边界,售后文档不一定对研发开放,薪资相关的资料更不可能让全员检索。RAG 场景下的权限过滤,本质上是在检索阶段加元数据过滤条件。

LangChain4j 的EmbeddingStoreRetriever支持过滤条件,比如:

EmbeddingStoreRetriever retriever = EmbeddingStoreRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(4) .minScore(0.5) // 只允许检索当前用户部门相关的文档 .filter(metadataKey("department").isEqualTo(userDepartment)) .build();

这里的关键是,权限过滤一定要在检索阶段做,不要在生成阶段做。如果先把所有部门的内容都检索出来、都放进上下文,再想办法不显示越权内容,那就是纸糊的墙。检索阶段就能通过元数据把范围收窄,模型根本不会看到无权限的内容,安全性和效率都能兼顾。

6.2 并发和缓存:不要让每个问题都重新检索一遍

企业内部知识库有一个特点:文档变更频率不高,但用户提问频率很高。这意味着频繁做相似度检索和向量化是浪费的。我的优化思路分两层。

第一层是检索缓存。如果用户问题和已缓存的查询在语义上高度相似,可以直接复用之前的 TopK 结果,不再走向量检索。实现上可以按用户或按部门维护一个简单的热点缓存,命中后响应能快一个量级。第二层是大模型调用的并发控制。模型推理服务通常有 QPS 限制,不加控制的话,几个高并发请求就能把服务打垮。我在接入层加了线程池和超时配置,超过等待时间直接返回降级提示,避免用户无限等待。

6.3 答案质量调优:效率最高的手段依次是这些

质量调优是知识库问答项目里最磨人、也最容易被低估的一环。我自己的调试顺序是:先调分块策略,再调检索参数,最后才考虑换模型。

分块策略的影响最直观。切片太短,资料给不到上下文,模型只能答出碎片化的内容;切片太长,检索到的片段包含大量无关信息,答案又容易跑偏。我实测的感受是,像制度类文档按段落切比较合适,FAQ 按句子切效果更好。检索参数里,maxResults可以先从 4 开始,如果模型答案经常遗漏关键信息,就调到 6 甚至 8;minScore则要看是否存在大量“看似相关其实无关”的返回结果,如果存在,就要调高。有条件的话,准备一组标准测试题,每次改动后用同一组题对比答案质量,这样调优才有依据。

6.4 踩坑记录:这几类问题我几乎都遇到过

第一个坑是版本问题。LangChain4j、Spring Boot、JDK 三个版本组合不对,编译或启动时直接报错,而且报错信息都不直观。第二个坑是 PDF 表格解析。PDFBox 提取表格时常常把单元格内容连成一串,语义完全错乱,我后来只能对表格类文档做专门的预处理。第三个坑是检索阈值设置不当,要么答案缺失,要么检索到一堆无关片段。第四个坑是上下文长度限制,早期测试时我把整篇文档都塞进 Prompt,很快触发模型输入上限,最后不得不调整分块大小和maxResults。这些问题的共性,是它们都不会在你跑通流程时报错,只会默默影响答案质量,所以排查时一定要回到数据和参数本身去验证。

最后说一点个人体会。这个项目真正花时间的地方不是 LangChain4j 的 API,而是文档清洗和分块参数的反复试。先用一个最小闭环跑通全流程,再把权限、缓存、引用这些工程细节一层层加进去,是我觉得最稳的路径。整套系统上线之后,维护成本没有想象中高,大部分精力都花在“有没有新文档需要入库”和“旧文档是不是过期了”这两件事上。只要入库流程做得顺手,知识库问答就是个越跑越值钱的系统。

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

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

立即咨询