纯Java构建企业知识库:LangChain4j+LangGraph4j实战

发布时间:2026/9/24 23:16:40
纯Java构建企业知识库:LangChain4j+LangGraph4j实战 最近在给团队搭企业知识库需求很直接内部有一堆技术文档、接口规范、会议纪要散落在各处。让大模型直接回答幻觉太重用 Python 那一套 LlamaIndex LangChain又跟现有 Java 技术栈割裂。所以我把目光投向了 LangChain4j 和 LangGraph4j用纯 Java 从零搭了一套 RAG 知识库问答系统。这套方案跑通之后效果相当能打今天把完整过程和踩坑经验拆开讲讲。这篇实战指南适合两类人一是想给团队搞内部知识库但不想引入 Python 服务的 Java 后端开发二是已经听说过 LangChain4j 但没系统跑通 RAG 全流程的 Spring Boot 使用者。博客会从架构设计、环境准备、文档处理、检索编排到优化上线一条线讲完所有代码都是我实际跑过的版本可以直接抄作业。1. 先理清架构别急着写代码1.1 为什么用 Java 构建 RAG 而不是继续用 Python很多 RAG 项目天然长在 Python 生态里LlamaIndex、LangChain、ChromaDB 这些工具链齐全社区资料多。但放在企业真实环境里尤其是金融、制造、政务这类以 Java 为绝对主力的后端体系里Python 服务往往是“二等公民”需要单独部署、单独监控、单独走发布流程还得处理 Python 环境和一系列依赖版本问题。如果知识库系统只是整个业务平台的一个模块那用 Java 直接嵌进现有服务里省掉的运维成本是非常可观的。我这次选的方案是 LangChain4j 作为核心框架LangGraph4j 做流程编排。LangChain4j 在 2024 年后进入快速迭代期补齐了文档加载、切块、嵌入、向量存储、Prompt 模板、输出解析这些 RAG 全链路组件LangGraph4j 则弥补了早期 LangChain4j 在复杂流程编排上的不足可以把多轮改写、条件路由、并行检索这些逻辑用有向图的方式表达清楚。这套组合在 Java 世界里基本对标了 Python 生态的“LangChain LangGraph”能力。1.2 LangChain4j 和 LangGraph4j 的定位划分很多初次接触的人会搞混这两个框架的关系。简单说LangChain4j 是瑞士军刀提供各种工具的封装LangGraph4j 是流水线图纸定义各个环节怎么串联。实际开发中我用 LangChain4j 的 DocumentLoader 加载文档、TextSegment 做切块、EmbeddingModel 计算向量、EmbeddingStore 做向量检索、ChatLanguageModel 调大模型对话用 LangGraph4j 把“问题改写—检索—生成”这几个步骤定义成有向图节点让流程可观测、可中断、可分支。有一点值得注意如果只是做个简单的“文档丢进去—提问—回答”只用 LangChain4j 就够了不需要上 LangGraph4j。LangGraph4j 的价值在流程复杂之后才体现出来比如多轮对话需要判断是否检索、检索结果置信度不够要触发二次检索、需要同时查多个知识库再融合排序。这些逻辑用 if-else 写会乱成一团但用图编排就清晰很多。能力维度LangChain4jLangGraph4j核心定位大模型应用开发工具包有状态流程编排引擎主要功能文档处理、嵌入、检索、LLM调用节点管理、状态传递、条件路由依赖关系可独立使用依赖LangChain4j组件适合场景简单RAG、对话、工具调用复杂RAG流程、Agent、多步骤任务这张表是给初学者看的框架认知框架。实际编码时你会在 LangGraph4j 的节点处理方法里大量调用 LangChain4j 的组件两者是协作关系而不是竞争关系。1.3 整体架构由哪些模块组成我设计的目标系统分成五个核心模块数据接入层、索引构建层、检索层、编排层、生成层。数据接入层负责从各种数据源本地文件、HTTP 接口、数据库拉取文档索引构建层把文档切块、嵌入、写入向量库检索层负责计算用户问题的向量表示并召回 TopK 相关片段编排层用 LangGraph4j 控制整个问答流程生成层把检索结果和用户问题拼进 Prompt 交给大模型输出答案。部署方式上我没有额外引入独立的向量数据库服务而是先用本地的 Lucene 向量索引跑通流程。这样做的原因很务实内网开发环境资源有限项目初期也不需要支撑高并发检索。等文档量级上来后再平滑切换为真正的向量数据库LangChain4j 的 EmbeddingStore 接口设计保证了切换成本很低后续章节我会专门说这个问题。2. 环境准备与项目初始化2.1 Maven 依赖引入与版本选型项目基于 Spring Boot 3.2JDK 17构建工具用的 Maven。LangChain4j 官方提供针对 Spring Boot 的 starter但为了更清晰地理解内部机制我选择手动引入核心依赖。这里强烈建议不要偷懒跳过这一步我曾经直接引入 langchain4j-spring-boot-starter 导致自动配置了一些用不到的内容排查问题反而浪费时间。手工装配虽然代码多几行但每一步都知道在干什么。properties langchain4j.version1.0.0-beta2/langchain4j.version langgraph4j.version1.0.0-beta1/langgraph4j.version /properties dependencies dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version${langchain4j.version}/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId version${langchain4j.version}/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-embeddings/artifactId version${langchain4j.version}/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-easy-rag/artifactId version${langchain4j.version}/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-lucene-store/artifactId version${langchain4j.version}/version /dependency dependency groupIdorg.bsc.langgraph4j/groupId artifactIdlanggraph4j-core/artifactId version${langgraph4j.version}/version /dependency dependency groupIdorg.bsc.langgraph4j/groupId artifactIdlanggraph4j-langchain4j/artifactId version${langgraph4j.version}/version /dependency /dependencies版本选择上需要专门说一下。LangChain4j 的版本迭代非常快API 差异很大网上很多教程用的还是 0.3x 甚至 0.1x参考价值有限。我选 1.0.0-beta2 是因为它已经确定了相当一部分的最终 API 形态并且对 LangGraph4j 的兼容性更好。LangGraph4j 目前版本号还在 1.0.0-beta1这个项目的作者是 BorislavBSC更新节奏稳定社区活跃度还可以遇到问题可以去 GitHub Issues 搜或者直接提回复速度不错。2.2 配置文件与基础装配在 application.yml 里我预留了模型服务相关配置。这套系统设计上支持对接 OpenAI 兼容接口所以只要内网部署的大模型服务支持该协议随便换。模型地址我用环境变量注入避免把内部地址写死在代码里。langchain4j: chat-model: base-url: ${LLM_BASE_URL:http://localhost:8080/v1} api-key: ${LLM_API_KEY:not-needed} model-name: ${LLM_MODEL:qwen2.5:7b} temperature: 0.3 timeout: 60s embedding-model: base-url: ${EMBEDDING_BASE_URL:http://localhost:8080/v1} api-key: ${EMBEDDING_API_KEY:not-needed} model-name: ${EMBEDDING_MODEL:bge-m3}这里解释两个配置关键点。temperature 我特意调到 0.3因为知识库问答任务希望模型尽量忠实于检索到的上下文而不是自由发挥如果调到 0.7 以上模型容易在不确定的时候自己“编”答案这跟 RAG 的初衷背道而驰。embedding 模型我选了 BGE-M3实测在中文场景下的检索效果明显优于 OpenAI 的 text-embedding-3-small 对中文的支持而且对内网部署友好模型尺寸适中。2.3 构建核心 Bean 装配统一的 Bean 配置类承担了所有初始化工作。从这段代码能清楚看到整个 RAG 链条上每个环节的对象是怎么串起来的——模型、切块器、向量库、检索器最终都被组装成一个 RetrievalAugmentor。Configuration public class RagConfiguration { Bean public ChatLanguageModel chatLanguageModel(Value(${langchain4j.chat-model.base-url}) String baseUrl, Value(${langchain4j.chat-model.api-key}) String apiKey, Value(${langchain4j.chat-model.model-name}) String modelName, Value(${langchain4j.chat-model.temperature}) Double temperature, Value(${langchain4j.chat-model.timeout}) Duration timeout) { return OpenAiChatModel.builder() .baseUrl(baseUrl) .apiKey(apiKey) .modelName(modelName) .temperature(temperature) .timeout(timeout) .logRequests(true) .logResponses(true) .build(); } Bean public EmbeddingModel embeddingModel(Value(${langchain4j.embedding-model.base-url}) String baseUrl, Value(${langchain4j.embedding-model.api-key}) String apiKey, Value(${langchain4j.embedding-model.model-name}) String modelName) { return OpenAiEmbeddingModel.builder() .baseUrl(baseUrl) .apiKey(apiKey) .modelName(modelName) .build(); } Bean public EmbeddingStoreTextSegment embeddingStore() { return new LuceneEmbeddingStore(data/rag-index); } Bean public DocumentSplitter documentSplitter() { return DocumentSplitters.recursive(500, 100); } Bean public EmbeddingStoreIngestor embeddingStoreIngestor(EmbeddingModel embeddingModel, EmbeddingStoreTextSegment embeddingStore, DocumentSplitter documentSplitter) { return EmbeddingStoreIngestor.builder() .documentSplitter(documentSplitter) .embeddingModel(embeddingModel) .embeddingStore(embeddingStore) .build(); } Bean public ContentRetriever contentRetriever(EmbeddingStoreTextSegment embeddingStore, EmbeddingModel embeddingModel) { return EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(5) .minScore(0.6) .build(); } }这段配置里的一个细节值得展开minScore 参数。它是检索结果的相似度阈值低于这个值的检索结果会被丢弃。我初版没设这个参数结果用户问一个跟知识库完全无关的问题时向量检索照样按“最不相似”的标准捞了一批垃圾片段然后大模型基于垃圾片段一本正经地胡编。加上 minScore 之后无关问题能被正确拒答。0.6 这个值是在测试集上调出来的文档类型不同这个参数也得跟着变只能作为起点参考。3. 文档接入与索引构建实操3.1 多渠道文档加载方案知识库的文档来源五花八门我这边主要处理四类本地 Markdown 技术文档、Word/PDF 接口规范、Confluence 导出的 HTML 页面、还有少量数据库表结构说明。LangChain4j 提供了统一的 DocumentLoader 抽象我写了一个 DocumentSource 接口来适配不同的来源这样才能在索引构建时统一处理。public interface DocumentSource { ListDocument load(); }以本地文件为例最常规的加载路径是 FileSystemDocumentLoader 配合 DocumentTypeDetector 自动识别文件类型。需要注意 LangChain4j 对 PDF 的解析依赖 Apache Tika对 Word 文档的处理能力相对弱一些所以需要先把 doc 转换成 docx否则解析出来的文本会带一堆乱码。Component public class LocalFileDocumentSource implements DocumentSource { private final Path rootPath; public LocalFileDocumentSource(Value(${doc.storage-path}) String storagePath) { this.rootPath Paths.get(storagePath); } Override public ListDocument load() { try (StreamPath paths Files.walk(rootPath)) { return paths.filter(Files::isRegularFile) .filter(p - { String name p.getFileName().toString().toLowerCase(); return name.endsWith(.md) || name.endsWith(.txt) || name.endsWith(.pdf) || name.endsWith(.html); }) .map(this::loadDocument) .collect(Collectors.toList()); } catch (IOException e) { throw new RuntimeException(Failed to load documents, e); } } private Document loadDocument(Path path) { Document document FileSystemDocumentLoader.loadDocument(path); // 保留文件路径作为元数据后续排查来源用 document.metadata().put(source, path.toString()); document.metadata().put(load-time, LocalDateTime.now().toString()); return document; } }这段代码另外一个隐蔽但重要的点是 metadata。我给每个文档写入 source 和 load-time 两个元数据字段。source 字段的作用非常大——当检索结果回答错误时我需要快速定位是哪份文档的哪个片段误导了模型如果没有这个字段纠错只能靠猜。元数据也可以用来做权限过滤比如根据文档所属部门限制检索范围这在企业场景下是一个很重要的需求。3.2 切块策略从踩坑到调优切块是 RAG 系统里最影响效果但又最容易被忽视的环节。我第一版直接用了固定长度 300 字符切块结果问“登录接口的超时时间是多少”这种问题答案经常说找不到。原因是登录接口的文档里接口入口、参数列表、异常码这些内容都在前面而超时时间的描述在文档末尾两个位置被切到了不同的块里导致检索时命中的块只包含部分信息。后来我换成了 RecursiveDocumentSplitter这种切块器的核心思路是先按段落分割再按句子分割最后按固定长度分割尽量保持语义完整性。LangChain4j 里对应的方法是 DocumentSplitters.recursive(maxSegmentSize, maxOverlapSize)。Bean public DocumentSplitter documentSplitter() { // 最大片段500字符重叠100字符 return DocumentSplitters.recursive(500, 100); }两个参数的设置逻辑我这里展开说得细一点。maxSegmentSize 是单个片段最大字符数设得太大一个片段包含的语义太多向量化后特征会被稀释检索时匹配精度下降设得太小片段缺少上下文回答时信息不全。500 是我针对技术文档测试后的折中值大部分接口说明里一个完整功能块的长度在几百到一千字符之间500 能保证语义单元基本完整。maxOverlapSize 是相邻片段的重叠字符数目的是避免切块刚好把一句话从中间切断导致语义断裂。100 字符差不多是一到两句话的长度重叠区能把断掉的上下文补回来。这里有一个常用测试方法切完块后随机抽几个片段人工读一遍如果发现大量片段在句中被截断说明 maxSegmentSize 太长如果发现大量片段内容重复度过高说明 overlap 太大了。3.3 索引构建全流程与增量更新索引构建我用了一个 CommandLineRunner在应用启动后自动扫描新增文档。整体流程可以概括为“加载—切块—嵌入—入库”四步。为了处理重复文档入索引的问题我给每个文档内容计算了 MD5 值存入元数据构建前先查一下这个文档是否处理过。Component public class IndexInitializer implements CommandLineRunner { private final ListDocumentSource documentSources; private final EmbeddingStoreIngestor ingestor; private final EmbeddingStoreTextSegment embeddingStore; Override public void run(String... args) { for (DocumentSource source : documentSources) { ListDocument documents source.load(); for (Document doc : documents) { String md5 DigestUtils.md5Hex(doc.text()); String existingMd5 searchExistingMd5(doc.metadata().getString(source)); if (md5.equals(existingMd5)) { continue; } ingestor.ingest(doc); saveMd5Mapping(doc.metadata().getString(source), md5); } } } private String searchExistingMd5(String source) { // 在实际实现中这里可以从数据库或单独文件中读取映射关系 return null; } private void saveMd5Mapping(String source, String md5) { // 将source与md5的映射持久化 } }增量更新的处理逻辑里有个容易被忽略的问题如果文档更新了正文但忘了文件名那 source 路径相同但 md5 变了应该走“先删旧嵌入再插新嵌入”的逻辑而不是直接跳过。我一开始只做了 md5 相同的跳过没有处理 md5 不同的更新场景导致文档改了之后系统永远回答旧内容。后来补上了删除旧记录的步骤才算闭环。4. LangGraph4j 实现检索问答编排4.1 为何选择 LangGraph4j 做编排而不是一顿 if-else当 RAG 流程简单到只有“检索—生成”两步时确实不需要 LangGraph4j一个 Service 方法就能搞定。但真实的知识库问答系统很快会遇到这些情况用户说“继续介绍一下刚才那个接口”这种指代性说法需要先改写问题才能检索知识库类型有多个需要根据问题内容路由到不同的检索器第一轮检索结果评分都不高但合并关键词召回后效果更好需要并行跑两路检索再做融合。这些逻辑叠加起来用 if-else 写就是一片混乱。LangGraph4j 的核心抽象是有向图。每个节点是一个加工步骤节点之间通过 State 传递数据边上可以挂条件判断决定下一步走哪个分支。这个模型非常契合 RAG 流程的演进逻辑。我最终用 LangGraph4j 实现的状态图包含四个核心节点改写节点RewriteQuery、检索节点RetrieveDocuments、生成节点GenerateAnswer、条件路由边ShouldRetrieve。4.2 定义状态模型LangGraph4j 的状态模型基于一个可变的 AgentState 类数据通过 key-value 的方式存储。为了方便类型安全我定义了一个 RAGState 子类把常用字段提取成 getter 方法。public class RAGState extends AgentState { public RAGState(MapString, Object initData) { super(initData); } public String getOriginalQuestion() { return (String) this.value(original_question); } public String getRewrittenQuestion() { return (String) this.value(rewritten_question); } public ListTextSegment getRetrievedSegments() { return (ListTextSegment) this.value(retrieved_segments); } public String getAnswer() { return (String) this.value(answer); } public void setOriginalQuestion(String question) { this.value(original_question, question); } public void setRewrittenQuestion(String question) { this.value(rewritten_question, question); } public void setRetrievedSegments(ListTextSegment segments) { this.value(retrieved_segments, segments); } public void setAnswer(String answer) { this.value(answer, answer); } }这里值得注意的一个细节是状态里的上一个节点输出并不需要显式声明消费者节点之间通过 state 的 key 隐式耦合。这也是 LangGraph4j 和普通责任链模式最大的区别节点不感知下一个节点是谁只要往 state 里写数据需要这个数据的下游节点自然能取到。这样增删节点不会影响现有代码逻辑。4.3 节点实现改写、检索、生成改写节点的核心价值在于处理多轮对话。用户接着上一轮问“那权限呢”如果不做改写直接拿“权限”两个字去向量库检索结果基本是噪声。我设计的改写 Prompt 要求模型把对话历史和当前问题合成为一个独立完整的问题。public class RewriteQueryNode implements NodeRAGState { private final ChatLanguageModel chatModel; public RewriteQueryNode(ChatLanguageModel chatModel) { this.chatModel chatModel; } Override public MapString, Object apply(RAGState state) { if (state.chatMemory() null || state.chatMemory().messages().isEmpty()) { return Map.of(rewritten_question, state.getOriginalQuestion()); } String rewritePrompt 你是一个问题改写助手。请将用户的问题结合对话历史改写为独立、完整、清晰的问题。 对话历史 %s 用户当前问题 %s 请只输出改写后的问题不要输出任何其他内容。 .formatted(formatMessages(state.chatMemory()), state.getOriginalQuestion()); String rewritten chatModel.generate(rewritePrompt); return Map.of(rewritten_question, rewritten); } }没有多轮上下文时直接跳过改写避免多一次模型调用增加延迟。这是性能优化的一个细节虽然一次改写调用通常也就几百毫秒但每减少一次调用整体链路就快一截。检索节点的实现里我同时用了向量检索和关键字检索最后做 RRF 融合排序。这里用了 LangChain4j 的 ContentRetriever但把 maxResults 调得比较高因为后续的融合排序会重新筛选。public class RetrieveDocumentsNode implements NodeRAGState { private final EmbeddingStoreContentRetriever vectorRetriever; private final KeywordContentRetriever keywordRetriever; Override public MapString, Object apply(RAGState state) { String question state.getRewrittenQuestion(); // 并行执行向量检索和关键词检索 ListContent vectorResults vectorRetriever.retrieve(question); ListContent keywordResults keywordRetriever.retrieve(question); // RRF融合排序 ListContent fusedResults RrfFusion.fuse( List.of(vectorResults, keywordResults), 60, // k常数 5 // 最终保留条数 ); ListTextSegment segments fusedResults.stream() .map(content - (TextSegment) content.textSegment()) .collect(Collectors.toList()); return Map.of(retrieved_segments, segments); } }生成节点是 RAG 链路的最后一棒目标是把检索到的片段和用户问题合成一个高质量答案。这里 Prompt 模板的质量直接决定了回答的可用性我在这上面迭代了很多轮最终版本要求模型严格遵守“仅基于给定内容回答”的原则并且明确要求不知道就说不知道。public class GenerateAnswerNode implements NodeRAGState { private final ChatLanguageModel chatModel; Override public MapString, Object apply(RAGState state) { String question state.getRewrittenQuestion(); ListTextSegment segments state.getRetrievedSegments(); String context segments.stream() .map(TextSegment::text) .collect(Collectors.joining(\n\n---\n\n)); String prompt 你是一个企业内部知识库问答助手。请仅根据下面提供的参考资料回答用户问题。 参考资料 %s 用户问题 %s 回答要求 1. 严格基于参考资料回答不要编造参考资料中不存在的信息 2. 如果参考资料无法回答问题请明确回答“根据现有资料无法回答该问题” 3. 回答时标明引用的参考文档编号如[1][2] 4. 保持简洁重点突出 .formatted(context, question); String answer chatModel.generate(prompt); return Map.of(answer, answer); } }4.4 图状态编排与条件边把节点串起来的核心在 Workflow 构建代码里。我用 langgraph4j-langchain4j 提供的适配器把流水线搭起来其中条件边 ShouldRetrieve 判断是否执行检索这是 Agentic RAG 的关键做法——不是每次都检索而是让模型判断当前问题是否需要外部知识像简单的寒暄可以直接回掉。Configuration public class RagWorkflowConfig { Bean public StateGraphRAGState ragGraph(RewriteQueryNode rewriteNode, RetrieveDocumentsNode retrieveNode, GenerateAnswerNode generateNode) { StateGraphRAGState graph new StateGraph(RAGState::new); graph.addNode(rewrite, rewriteNode); graph.addNode(retrieve, retrieveNode); graph.addNode(generate, generateNode); graph.setEntryPoint(rewrite); graph.addEdge(rewrite, retrieve); graph.addEdge(retrieve, generate); graph.setEndPoint(generate); return graph; } Bean public LangGraphRagService ragService(StateGraphRAGState ragGraph) { return new LangGraphRagService(ragGraph); } }我实现的 LangGraphRagService 封装了图执行逻辑对外只暴露一个简单方法传入用户问题和会话 ID返回答案。这里需要注意 StateGraph.compile() 会生成一个可执行对象每轮对话应该使用全新的初始状态避免上一轮数据污染下一轮。Service public class LangGraphRagService { private final CompiledGraphRAGState compiledGraph; public LangGraphRagService(StateGraphRAGState graph) { this.compiledGraph graph.compile(); } public String answer(String question, String sessionId) { // 构造初始状态这里的chat_memory需要通过sessionId从会话存储中加载 MapString, Object initState new HashMap(); initState.put(original_question, question); initState.put(chat_memory, chatMemoryStore.get(sessionId)); RAGState initialState new RAGState(initState); var result compiledGraph.invoke(initialState); // 将本轮问答写入记忆 chatMemoryStore.add(sessionId, question, result.getAnswer()); return result.getAnswer(); } }5. 更聪明的 RAG多路召回与融合排序5.1 从向量检索到混合检索单纯依赖向量检索的企业知识库在精确匹配场景下常常力不从心。比如用户问“订单状态为已支付且金额大于100元的记录怎么查”向量检索会把“订单”“状态”“支付”“金额”这些词向量化后找语义相近的片段但数据库字段级别的精确条件匹配向量检索不如传统的关键词检索来得准。这让我把方向转向了混合检索一路走向量检索抓语义相似另一路走传统关键词检索抓精确匹配最后用 RRFReciprocal Rank Fusion融合排序。RRF 的核心原理很朴素每个文档在多个结果列表里都有一个排名位置融合得分等于各列表中位置倒数的累加排名越靠前得分越高。这样做的好处是不用统一不同检索算法的分数范围因为各自算出来的相似度分数量纲可能完全不一样直接相加没有意义。召回方式优势劣势适用场景纯向量检索语义理解强能处理同义词精确词匹配弱开放性问题、概念理解纯关键词检索精确匹配强可解释性好无法处理语义鸿沟特定名词、代码、ID查询混合检索RRF兼顾语义和精确匹配多一次检索耗时企业知识库综合问答5.2 RRF 的工程实现与缺陷规避LangChain4j 和 LangGraph4j 默认的 RRF 实现我实际测试后发现有个比较隐蔽的坑去重逻辑存在缺陷。当多个检索器返回相同片段时默认实现按对象引用去重而非按内容去重导致同一个片段在最终结果里出现多次而且融合分数被重复累加。更严重的是如果两路检索都命中同一个文本片段但封装对象不同RRF 得分会被算两次排序结果失真。我的解决方案是自定义融合器按 TextSegment 的唯一标识比如文本的 MD5去重后再算分。这里直接把我一直在用的实现贴出来public class RrfFusion { private static final int DEFAULT_K 60; public static ListContent fuse(ListListContent rankings, int k, int topN) { MapString, Double scoreMap new HashMap(); MapString, Content contentMap new HashMap(); for (ListContent ranking : rankings) { for (int i 0; i ranking.size(); i) { Content content ranking.get(i); String key DigestUtils.md5Hex(content.textSegment().text()); double score 1.0 / (k i 1); scoreMap.merge(key, score, Double::sum); contentMap.putIfAbsent(key, content); } } return scoreMap.entrySet().stream() .sorted(Map.Entry.String, DoublecomparingByValue().reversed()) .limit(topN) .map(entry - contentMap.get(entry.getKey())) .collect(Collectors.toList()); } }设计这个融合器的核心思路是scoreMap 按照 key 累加 RRF 分数contentMap 按 key 保存第一个出现的对象最后按分数降序取 topN。因为 key 是内容的 MD5所以内容相同但来自不同检索器同一片段不会重复计分相当于把默认实现的去重 bug 绕过去了。5.3 多轮对话的记忆管理多轮对话是知识库问答系统的刚需但处理不好会变成灾难。我设计了一个 ChatMemoryStore核心目标是两件事给 LLM 提供足够的对话上下文供改写节点使用同时控制上下文长度以免超出模型窗口限制。具体做法是每个会话维护一个消息列表改写节点使用时只取最近 5 轮消息。这里不要傻乎乎地把所有历史消息全交给模型——对话超过 20 轮后历史消息会占用大量 token而且大部分早期消息跟当前问题毫无关系反而干扰判断。LangChain4j 提供了 MessageWindowChatMemory可以按窗口大小自动丢弃早期消息比较省心。Component public class ChatMemoryStore { private final MapString, MessageWindowChatMemory memories new ConcurrentHashMap(); public MessageWindowChatMemory get(String sessionId) { return memories.computeIfAbsent(sessionId, id - MessageWindowChatMemory.builder() .maxMessages(10) .chatMemoryStore(new InMemoryChatMemoryStore()) .build() ); } public void add(String sessionId, String question, String answer) { MessageWindowChatMemory memory get(sessionId); memory.add(UserMessage.from(question)); memory.add(AiMessage.from(answer)); } }maxMessages 设成 10 意味着最多保留 5 轮对话这是我在回答质量和 token 成本之间权衡出来的值。如果业务场景里用户经常连续问十几个相关问题可以适当调大但建议每加 10 条消息观察一次 API 延迟变化模型窗口越长推理耗时越长成本并不是唯一考量。6. 优化实践与常见问题排查6.1 检索效果调优的几条实战经验知识库问答效果不好80% 的原因出在检索环节而不是生成环节。如果你发现答案总是“有些相关但答非所问”大概率是召回的内容不对模型拿着错误的上下文写了看似合理的答案。我整理了三条最高性价比的调优路径。第一条是检查切块大小和重叠量。500/100 这套参数在技术文档上表现不错但如果你处理的是制度规范这种大段文字的文档建议把 maxSegmentSize 提到 800 试试如果是产品 FAQ 这种短问答格式300 就足够了。切块参数没有万能值只能拿真实文档测试后确认。第二条是调整 minScore 阈值和 maxResults 数量。minScore 设太高会导致该召回的相关内容被过滤掉设太低会引入噪声片段。maxResults 一般设 3 到 5 个比较合适太少信息不足太多会把模型的注意力扯散。注意 LangChain4j 默认相似度算法是余弦相似度取值范围是 [-1, 1]但超过 0.8 的文档片段在我的场景里已经非常罕见0.6 阈值更实用。第三条是给 Prompt 增加“金丝雀测试”。在系统联调阶段我准备了一批标准问题集包含正常问题、模糊问题、完全无关问题三种类型每次修改检索参数后跑一遍问题集记录正确率变化。这种方法比凭感觉调参可靠得多也可以当作回归测试集防止后续改动搞坏已有能力。6.2 常见问题速查表问题现象可能原因解决方案答案出现一本正经的胡编minScore 设太低噪声片段混入调高 minScore 阈值检查检索到的上下文是否相关明明库里有答案但检索不到切块破坏了语义完整性换用递归切块器调整 maxSegmentSize中文文档乱码文件编码不是 UTF-8加载时指定字符集用 StandardCharsets.UTF_8文档更新后回答旧内容增量索引没有处理文档变更比较 MD5变更时先删旧记录再重新嵌入向量维度不匹配异常索引库用了一个嵌入模型当前用的另一个删除旧索引目录重建索引模型总是答超纲内容temperature 过高降到 0.2~0.3 之间多轮对话指代无法理解改写节点没有生效确认对话记忆是否正确写入并与改写节点打通并发问答导致内存暴涨Lucene 索引在内存中占用过高评估切换到独立向量数据库或增加 JVM 堆内存6.3 从本地向量库迁移到独立向量数据库本地 Lucene 索引对单机部署、单用户或低并发场景非常合适但一旦多实例部署或者文档量级达到百万级就需要考虑迁移到独立的向量数据库。LangChain4j 在 EmbeddingStore 层面做了很好的抽象业务代码无感知切换。切换的方式很简单把 Bean 方法从 LuceneEmbeddingStore 换掉即可。比如用 PGVector 作为向量库的场景引入 langchain4j-pgvector 依赖然后把 embeddingStore 的 Bean 方法改成依赖 DataSource 构建Bean public EmbeddingStoreTextSegment embeddingStore(DataSource dataSource) { return PgVectorEmbeddingStore.builder() .dataSource(dataSource) .tableName(rag_document_segments) .dimension(1024) // 与BGE-M3的向量维度保持一致 .build(); }这里的 dimension 参数必须跟你用的嵌入模型输出维度严格对上BGE-M3 的输出维度是 1024OpenAI 的 text-embedding-3-small 是 1536填错了建表都会失败。迁移后原来的索引数据需要全量重建一次没有平滑迁移这条捷径。7. 测试与效果评估7.1 建立回归问题集做 RAG 项目最怕的就是“感觉好像行了”就直接上线结果用户反馈各种拉胯。我建议在动手调优前先花两个小时整理一套回归问题集。这套问题集要覆盖四类问题有明确答案的事实类提问比如“XX系统超时时间默认是多少”、需要总结归纳的开放类提问比如“XX模块的支付流程是怎样的”、跨文档的综合提问比如“XX接口和XX接口在参数校验上有什么异同”、完全超出知识范围的无关提问比如“帮我写一首关于春天的诗”。有了问题集之后每次修改切块参数、检索参数或 Prompt 模板都跑一遍全量问题集并记录每道题的回答是否满足预期。这个过程的本质是给 RAG 系统的“玄学”部分建立可量化的反馈闭环避免凭直觉改来改去反而越改越差。7.2 判定回答质量的三个维度我在实践中把回答质量判定拆成三个独立维度回答必须同时满足才算合格。事实准确性是第一位检查答案里的关键信息数字、时间、接口名、参数名是否能在参考文档中找到依据引用完整性是第二位模型回答的引用标注应当能对应到实际检索到的片段防止模型“张冠李戴”可操作性放在最后对于操作类问题答案里的步骤是否足以让一个没做过的人完成操作。这三个维度也对应着 RAG 系统的三个关键环节事实准确性主要受检索召回质量影响引用完整性主要受 Prompt 约束影响可操作性主要受切块粒度影响。哪个维度出了问题就针对对应环节排查比整体盲调效率高得多。7.3 线上监控与反馈收集系统上线后还需要持续监控效果。我在应用中记录了每次问答的原始问题、改写后的问题、检索到的片段列表、最终答案和响应耗时存到日志表里。每周做一次抽样人工评估标记回答质量积累一段时间后就能看到改进方向。另外很重要的一点是收集用户主动反馈——在答案下方加一个“这个回答是否有帮助”的点赞/点踩按钮虽然点击率通常不高但踩的数据价值极高往往能直接暴露检索或切块的问题。总结与扩展思考从零开始搭建这个 Java 版 RAG 知识库系统前后大概花了三周时间。第一周搭通了最小可用链路第二周集中调优检索效果第三周补上多轮对话、混合检索和评估体系。个人最大的体会有三点一是 RAG 的瓶颈不在 LLM 而在检索投入时间优化切块和召回远比调 Prompt 更有效二是 LangGraph4j 的编排能力在流程复杂之后价值才会显现早期简单流程没必要硬上三是评估体系必须尽早建立否则改了一周参数都不知道是变好了还是变坏了。后续这个系统还可以继续扩展的方向我简单说几个。实体级别的 Ontology RAG 可以进一步约束知识结构让系统理解实体与实体之间的关系而不是单纯文档片段Agentic RAG 可以让模型自主决定检索次数和检索策略与 MCP 协议结合则能让知识库系统更方便地接入外部工具实现更丰富的 Agent 能力。不过这些都是后话先把基础链路跑稳比追新概念重要得多。如果你正在评估 Java 生态做知识库系统的可行性我的结论是完全可行而且 LangChain4j LangGraph4j 的组合已经能覆盖绝大多数企业内部知识库需求。把这个项目跑通之后你收获的不只是一套代码还有一整套关于切块、嵌入、检索、融合、评估的工程方法论这套方法论在你以后面对任何知识密集型业务时都会用得上。