最近在给团队搭企业知识库,需求很直接:内部有一堆技术文档、接口规范、会议纪要,散落在各处。让大模型直接回答,幻觉太重;用 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 写会乱成一团,但用图编排就清晰很多。
| 能力维度 | LangChain4j | LangGraph4j |
|---|---|---|
| 核心定位 | 大模型应用开发工具包 | 有状态流程编排引擎 |
| 主要功能 | 文档处理、嵌入、检索、LLM调用 | 节点管理、状态传递、条件路由 |
| 依赖关系 | 可独立使用 | 依赖LangChain4j组件 |
| 适合场景 | 简单RAG、对话、工具调用 | 复杂RAG流程、Agent、多步骤任务 |
这张表是给初学者看的框架认知框架。实际编码时你会在 LangGraph4j 的节点处理方法里大量调用 LangChain4j 的组件,两者是协作关系而不是竞争关系。
1.3 整体架构由哪些模块组成
我设计的目标系统分成五个核心模块:数据接入层、索引构建层、检索层、编排层、生成层。数据接入层负责从各种数据源(本地文件、HTTP 接口、数据库)拉取文档;索引构建层把文档切块、嵌入、写入向量库;检索层负责计算用户问题的向量表示并召回 TopK 相关片段;编排层用 LangGraph4j 控制整个问答流程;生成层把检索结果和用户问题拼进 Prompt 交给大模型输出答案。
部署方式上我没有额外引入独立的向量数据库服务,而是先用本地的 Lucene 向量索引跑通流程。这样做的原因很务实:内网开发环境资源有限,项目初期也不需要支撑高并发检索。等文档量级上来后再平滑切换为真正的向量数据库,LangChain4j 的 EmbeddingStore 接口设计保证了切换成本很低,后续章节我会专门说这个问题。
2. 环境准备与项目初始化
2.1 Maven 依赖引入与版本选型
项目基于 Spring Boot 3.2,JDK 17,构建工具用的 Maven。LangChain4j 官方提供针对 Spring Boot 的 starter,但为了更清晰地理解内部机制,我选择手动引入核心依赖。这里强烈建议不要偷懒跳过这一步,我曾经直接引入 langchain4j-spring-boot-starter 导致自动配置了一些用不到的内容,排查问题反而浪费时间。手工装配虽然代码多几行,但每一步都知道在干什么。
<properties> <langchain4j.version>1.0.0-beta2</langchain4j.version> <langgraph4j.version>1.0.0-beta1</langgraph4j.version> </properties> <dependencies> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>${langchain4j.version}</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>${langchain4j.version}</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-embeddings</artifactId> <version>${langchain4j.version}</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-easy-rag</artifactId> <version>${langchain4j.version}</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-lucene-store</artifactId> <version>${langchain4j.version}</version> </dependency> <dependency> <groupId>org.bsc.langgraph4j</groupId> <artifactId>langgraph4j-core</artifactId> <version>${langgraph4j.version}</version> </dependency> <dependency> <groupId>org.bsc.langgraph4j</groupId> <artifactId>langgraph4j-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,这个项目的作者是 Borislav(BSC),更新节奏稳定,社区活跃度还可以,遇到问题可以去 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 EmbeddingStore<TextSegment> embeddingStore() { return new LuceneEmbeddingStore("data/rag-index"); } @Bean public DocumentSplitter documentSplitter() { return DocumentSplitters.recursive(500, 100); } @Bean public EmbeddingStoreIngestor embeddingStoreIngestor(EmbeddingModel embeddingModel, EmbeddingStore<TextSegment> embeddingStore, DocumentSplitter documentSplitter) { return EmbeddingStoreIngestor.builder() .documentSplitter(documentSplitter) .embeddingModel(embeddingModel) .embeddingStore(embeddingStore) .build(); } @Bean public ContentRetriever contentRetriever(EmbeddingStore<TextSegment> 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 { List<Document> 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 List<Document> load() { try (Stream<Path> 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 List<DocumentSource> documentSources; private final EmbeddingStoreIngestor ingestor; private final EmbeddingStore<TextSegment> embeddingStore; @Override public void run(String... args) { for (DocumentSource source : documentSources) { List<Document> 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(Map<String, Object> initData) { super(initData); } public String getOriginalQuestion() { return (String) this.value("original_question"); } public String getRewrittenQuestion() { return (String) this.value("rewritten_question"); } public List<TextSegment> getRetrievedSegments() { return (List<TextSegment>) 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(List<TextSegment> 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 Node<RAGState> { private final ChatLanguageModel chatModel; public RewriteQueryNode(ChatLanguageModel chatModel) { this.chatModel = chatModel; } @Override public Map<String, 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 Node<RAGState> { private final EmbeddingStoreContentRetriever vectorRetriever; private final KeywordContentRetriever keywordRetriever; @Override public Map<String, Object> apply(RAGState state) { String question = state.getRewrittenQuestion(); // 并行执行向量检索和关键词检索 List<Content> vectorResults = vectorRetriever.retrieve(question); List<Content> keywordResults = keywordRetriever.retrieve(question); // RRF融合排序 List<Content> fusedResults = RrfFusion.fuse( List.of(vectorResults, keywordResults), 60, // k常数 5 // 最终保留条数 ); List<TextSegment> segments = fusedResults.stream() .map(content -> (TextSegment) content.textSegment()) .collect(Collectors.toList()); return Map.of("retrieved_segments", segments); } }生成节点是 RAG 链路的最后一棒,目标是把检索到的片段和用户问题合成一个高质量答案。这里 Prompt 模板的质量直接决定了回答的可用性,我在这上面迭代了很多轮,最终版本要求模型严格遵守“仅基于给定内容回答”的原则,并且明确要求不知道就说不知道。
public class GenerateAnswerNode implements Node<RAGState> { private final ChatLanguageModel chatModel; @Override public Map<String, Object> apply(RAGState state) { String question = state.getRewrittenQuestion(); List<TextSegment> 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 StateGraph<RAGState> ragGraph(RewriteQueryNode rewriteNode, RetrieveDocumentsNode retrieveNode, GenerateAnswerNode generateNode) { StateGraph<RAGState> 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(StateGraph<RAGState> ragGraph) { return new LangGraphRagService(ragGraph); } }我实现的 LangGraphRagService 封装了图执行逻辑,对外只暴露一个简单方法:传入用户问题和会话 ID,返回答案。这里需要注意 StateGraph.compile() 会生成一个可执行对象,每轮对话应该使用全新的初始状态,避免上一轮数据污染下一轮。
@Service public class LangGraphRagService { private final CompiledGraph<RAGState> compiledGraph; public LangGraphRagService(StateGraph<RAGState> graph) { this.compiledGraph = graph.compile(); } public String answer(String question, String sessionId) { // 构造初始状态,这里的chat_memory需要通过sessionId从会话存储中加载 Map<String, 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元的记录怎么查”,向量检索会把“订单”“状态”“支付”“金额”这些词向量化后找语义相近的片段,但数据库字段级别的精确条件匹配,向量检索不如传统的关键词检索来得准。
这让我把方向转向了混合检索:一路走向量检索抓语义相似,另一路走传统关键词检索抓精确匹配,最后用 RRF(Reciprocal 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 List<Content> fuse(List<List<Content>> rankings, int k, int topN) { Map<String, Double> scoreMap = new HashMap<>(); Map<String, Content> contentMap = new HashMap<>(); for (List<Content> 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, Double>comparingByValue().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 Map<String, 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 EmbeddingStore<TextSegment> embeddingStore(DataSource dataSource) { return PgVectorEmbeddingStore.builder() .dataSource(dataSource) .tableName("rag_document_segments") .dimension(1024) // 与BGE-M3的向量维度保持一致 .build(); }这里的 dimension 参数必须跟你用的嵌入模型输出维度严格对上,BGE-M3 的输出维度是 1024,OpenAI 的 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 的组合已经能覆盖绝大多数企业内部知识库需求。把这个项目跑通之后,你收获的不只是一套代码,还有一整套关于切块、嵌入、检索、融合、评估的工程方法论,这套方法论在你以后面对任何知识密集型业务时都会用得上。