1. 为什么我选择用Java从零搭一套RAG知识库
先说结论:这套东西我用了一个周末跑通第一版,第二周开始往生产环境上靠,中间踩的坑比预想的多,但整体收益远超预期。标题里提到的LangChain4j + LangGraph4j组合,是我对比了 Spring AI、直接调大模型 API、以及 Python 侧方案之后定下来的路线。原因很直接——我的主技术栈是 Java,团队里没人愿意为了一个知识库系统再维护一套 Python 服务,而 LangChain4j 把 RAG 的核心链路(文档加载、切分、向量化、检索、生成)都封装成了 Java 原生 API,LangGraph4j 则补上了多步编排和状态流转这块短板。
RAG(Retrieval-Augmented Generation,检索增强生成)说白了就是给大模型配一个"外挂记忆库"。大模型本身的知识是训练时冻结的,你问它公司内部文档、最新产品手册、私有业务规则,它要么瞎编要么说不知道。RAG 的做法是:先把你的文档切块、向量化、存进向量库;用户提问时,先把问题也向量化,去库里捞出最相关的几块原文,再把这些原文塞进提示词一起发给大模型,让它"看着材料回答"。这样既避免了重新训练模型的高昂成本,又能保证答案有据可查。
这套系统适合谁?我认为三类人最该动手做一遍:一是Java 后端,想在自己的业务系统里加一个"智能问答"能力,又不想引入异构服务;二是做企业知识管理的同学,手里有一堆 Word、PDF、Confluence 导出文档,想让它"活"起来;三是正在准备Java 面试的朋友——现在 RAG、Agent、向量检索这些词已经频繁出现在中高级岗位的面试题里,光背八股文不够,手上得有一个能讲清楚链路的项目。
我下面写的内容,全部基于我实际跑通的版本,代码能抄,参数能改,坑我也标出来了。你不需要是算法工程师,但得会写 Java、会用 Maven、能看懂 JSON。
2. 整体架构设计与技术选型拆解
2.1 为什么是 LangChain4j 而不是 Spring AI
这是被问得最多的问题,我自己也纠结过。Spring AI 的优势是跟 Spring 生态无缝集成,@Bean一配就能用,如果你整个项目就是 Spring Boot,上手确实快。但它的问题在于抽象层次偏高,很多 RAG 的细节(比如切分策略、检索后的重排序、多路召回融合)你想深度定制时,会发现要么得绕开它的封装,要么得等社区版本更新。
LangChain4j 的定位更"底层"一些,它把 RAG 拆成了清晰的组件:DocumentLoader、DocumentSplitter、EmbeddingModel、EmbeddingStore、ContentRetriever、ChatLanguageModel。每个组件你都能替换成自己的实现。我实测下来,做Agentic RAG(让模型自己决定要不要检索、检索几轮)时,LangChain4j 的灵活度明显更高。
至于 LangGraph4j,它是 LangGraph 的 Java 移植版,核心价值是把 RAG 流程从"线性管道"变成"状态图"。普通的 RAG 是"检索→拼接→生成"一条直线,但真实场景往往需要:先判断问题类型→决定走不走检索→检索后评估相关性→不相关就改写查询重试→最后生成。这种带分支、带循环、带状态的流程,用 LangGraph4j 表达起来非常自然。
| 对比维度 | Spring AI | LangChain4j + LangGraph4j |
|---|---|---|
| 上手速度 | 快,Spring 风格 | 中等,需理解组件模型 |
| 定制灵活度 | 一般 | 高,组件可替换 |
| 多步编排能力 | 弱 | 强,原生状态图 |
| 社区活跃度 | 高 | 高,更新频繁 |
| 适合场景 | 标准 RAG、快速验证 | 复杂 RAG、Agentic 流程 |
我的建议是:如果只是做个 demo 或者标准问答,Spring AI 够用;但凡涉及多轮检索、查询改写、条件分支,直接上 LangChain4j + LangGraph4j,别中途换。
2.2 核心链路拆解:一条数据从文档到答案的旅程
整套系统的数据流我画不出图(这里也不让画),但可以用文字讲清楚。它分两个阶段:离线索引阶段和在线检索生成阶段。
离线阶段做四件事:加载文档(PDF、Word、Markdown、网页都行)→ 切分成小块(chunk)→ 每块调用 Embedding 模型转成向量 → 存进向量库。这一步是"一次性"的,文档更新时增量做。
在线阶段做五件事:用户提问 → 问题向量化 → 向量库相似度检索 Top-K → (可选)重排序 + 多路召回融合 → 把检索结果和问题拼成提示词 → 大模型生成答案。
LangGraph4j 介入的是在线阶段。我把整个在线流程定义成一个状态图,节点包括:classifyQuery(判断问题类型)、retrieve(检索)、gradeDocuments(评估检索质量)、rewriteQuery(改写查询)、generate(生成答案)。边则定义了流转逻辑,比如gradeDocuments发现文档不相关,就回到rewriteQuery重试,最多重试两轮。
2.3 向量库和 Embedding 模型的选型逻辑
向量库我选了Milvus(本地开发用 Docker 起单机版),原因是它对 Java 客户端支持好、性能稳、支持标量过滤(这点很重要,后面讲多租户时会用到)。轻量场景也可以用Chroma或PgVector,如果你已经有 PostgreSQL,PgVector 是最省事的,不用额外维护一个中间件。
Embedding 模型我用的是BGE-M3,通过本地部署的推理服务调用。选它的理由:中文效果好、支持多语言、维度 1024 不算太高、开源可商用。如果你不想自己部署,用云厂商的 Embedding API 也行,LangChain4j 对这些都有适配。这里有个关键点:Embedding 模型一旦选定,索引和查询必须用同一个模型,否则向量空间对不上,检索结果全是噪声。我见过有人索引用 A 模型、查询用 B 模型,然后抱怨"检索命中率极低",这就是典型的踩坑。
3. 环境搭建与核心依赖配置
3.1 Maven 依赖清单与版本选择
依赖这块我踩过版本冲突的坑,所以直接把我的pom.xml关键部分贴出来。核心是langchain4j、langchain4j-open-ai(或你用的模型适配包)、langchain4j-milvus、langgraph4j-core。
<properties> <langchain4j.version>0.35.0</langchain4j.version> <langgraph4j.version>1.0.0</langgraph4j.version> <java.version>17</java.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-milvus</artifactId> <version>${langchain4j.version}</version> </dependency> <dependency> <groupId>org.bsc.langgraph4j</groupId> <artifactId>langgraph4j-core</artifactId> <version>${langgraph4j.version}</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-document-parser-apache-pdfbox</artifactId> <version>${langchain4j.version}</version> </dependency> </dependencies>注意:LangChain4j 的版本迭代很快,0.35.0 是我写这篇时稳定的版本。升级前务必看官方 release notes,它有过几次破坏性变更,比如
EmbeddingStore接口的方法签名调整。
Java 版本我强烈建议17 或以上,因为 LangGraph4j 用到了 record 和 sealed class 这些特性,Java 8 跑不起来。如果你项目还锁在 Java 8,那这套方案得先评估升级成本。
3.2 向量库的本地启动与连接配置
Milvus 我用 Docker Compose 起,配置文件精简版如下:
version: '3.5' services: etcd: image: quay.io/coreos/etcd:v3.5.5 environment: - ETCD_AUTO_COMPACTION_MODE=revision - ETCD_AUTO_COMPACTION_RETENTION=1000 volumes: - ./volumes/etcd:/etcd command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd minio: image: minio/minio:RELEASE.2023-03-20T20-16-18Z environment: MINIO_ACCESS_KEY: minioadmin MINIO_SECRET_KEY: minioadmin volumes: - ./volumes/minio:/minio_data command: minio server /minio_data standalone: image: milvusdb/milvus:v2.3.3 command: ["milvus", "run", "standalone"] environment: ETCD_ENDPOINTS: etcd:2379 MINIO_ADDRESS: minio:9000 ports: - "19530:19530" - "9091:9091" depends_on: - etcd - minio启动后19530是 gRPC 端口,Java 客户端连这个。连接代码:
MilvusEmbeddingStore embeddingStore = MilvusEmbeddingStore.builder() .host("127.0.0.1") .port(19530) .collectionName("knowledge_base") .dimension(1024) .build();这里的dimension必须和 Embedding 模型输出维度一致,BGE-M3 是 1024,OpenAI 的 text-embedding-3-small 是 1536。填错了要么报错,要么检索结果完全乱套。
3.3 模型接入:本地推理服务 vs 云 API
我两种都试过。本地部署 BGE-M3 用的是一个轻量推理服务,好处是数据不出内网、没有调用费用、延迟稳定;坏处是要占 GPU 资源,机器配置不够时吞吐上不去。云 API 的好处是省心,坏处是数据要出去、有成本、有网络延迟。
LangChain4j 接入本地服务时,只要对方兼容 OpenAI 的接口格式,就能直接用OpenAiEmbeddingModel,把baseUrl指过去就行:
EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder() .baseUrl("http://localhost:8000/v1") .apiKey("not-needed") .modelName("bge-m3") .build();提示:很多本地推理服务默认不校验 apiKey,但 LangChain4j 的 builder 要求必填,随便填个字符串即可,别留空。
4. 文档处理与索引构建的实操细节
4.1 文档加载:不同格式的处理策略
LangChain4j 提供了多种DocumentLoader。PDF 用ApachePdfBoxDocumentParser,Word 用ApachePoiDocumentParser,纯文本和 Markdown 直接读文件。我实际项目里文档来源杂,所以写了一个分发器:
public List<Document> loadDocument(Path path) { String fileName = path.getFileName().toString().toLowerCase(); DocumentParser parser; if (fileName.endsWith(".pdf")) { parser = new ApachePdfBoxDocumentParser(); } else if (fileName.endsWith(".docx")) { parser = new ApachePoiDocumentParser(); } else { parser = new TextDocumentParser(); } return parser.parse(path); }这里有个坑:PDF 解析出来的文本经常带乱码或断行。尤其是扫描件,PdfBox 提取出来是空的,因为它本质是图片。这种情况要么上 OCR,要么在入库前人工筛一遍。我的做法是加一个校验:解析后文本长度小于 50 字符的,直接标记为"待人工处理",不进入索引。
Word 文档还有个细节:表格内容。Poi 解析表格时,默认会把单元格内容按行拼接,但表头和数据行的对应关系会丢失。如果你的知识库里有大量表格(比如产品参数表),建议单独处理表格,转成"字段名: 值"的键值对文本再入库,检索效果会好很多。
4.2 文本切分:chunk 大小和重叠度的取舍
切分是 RAG 里最容易被低估的环节。切太大,检索出来的块包含太多无关信息,稀释了相关性;切太小,语义不完整,模型拿到半句话没法回答。我的经验值是:中文文档 chunk 大小 300-500 字,重叠 50-80 字。
LangChain4j 的DocumentSplitters.recursive()支持按段落、句子、字符递归切分,比固定长度切分效果好:
DocumentSplitter splitter = DocumentSplitters.recursive( 500, // maxSegmentSizeInChars 80, // maxOverlapSizeInChars new OpenAiTokenizer() ); List<TextSegment> segments = splitter.split(document);重叠度为什么要有?因为一句话可能正好被切在边界上,前半句在块 A、后半句在块 B,检索时只捞到块 A,语义就断了。重叠 80 字能保证边界处的语义至少在一个块里是完整的。
注意:
OpenAiTokenizer是按 token 估算的,中文一个汉字大约 1-2 个 token。如果你用字符数控制,直接用DocumentSplitters.recursive(maxChars, overlapChars)那个重载,别传 tokenizer,否则实际切出来的块会比你预期的小。
4.3 向量化与批量入库的性能优化
向量化是 CPU/GPU 密集型操作,逐条调用 Embedding 接口会非常慢。LangChain4j 的EmbeddingStoreIngestor支持批量处理,但默认批大小可能不适合你的场景。我实测下来,批大小设 32-64 比较稳,太大容易触发服务端超时,太小则吞吐上不去。
EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder() .documentSplitter(splitter) .embeddingModel(embeddingModel) .embeddingStore(embeddingStore) .build(); ingestor.ingest(documents);入库时我还加了元数据(metadata),比如source(来源文件)、page(页码)、category(分类)。这些元数据在检索时能做过滤,比如"只在产品手册里搜",能大幅提升准确率。Milvus 支持标量字段过滤,LangChain4j 的MetadataFilter可以表达这类条件。
5. 用 LangGraph4j 编排 Agentic RAG 流程
5.1 状态图的基本概念与节点定义
LangGraph4j 的核心是StateGraph。你先定义一个状态类型(通常是个 Map 或自定义对象),然后往里加节点(Node)和边(Edge)。节点是执行单元,边决定下一步走哪。
我的状态定义简化版:
public class RagState { private String query; // 原始问题 private String rewrittenQuery; // 改写后的问题 private List<TextSegment> docs; // 检索到的文档 private String answer; // 最终答案 private int retryCount; // 重试次数 // getters/setters 省略 }节点我用函数式的方式定义,每个节点接收状态、返回更新后的状态。比如检索节点:
NodeAction<RagState> retrieveNode = state -> { String q = state.rewrittenQuery() != null ? state.rewrittenQuery() : state.query(); List<TextSegment> docs = retriever.retrieve(q); return Map.of("docs", docs); };5.2 条件边:让流程学会"判断"和"回头"
这是 LangGraph4j 最值钱的地方。普通 RAG 检索完就直接生成,但检索质量差的时候,生成出来的答案就是垃圾。我加了一个gradeDocuments节点,用大模型给检索到的文档打分(相关/不相关),然后通过条件边决定下一步:
graph.addConditionalEdges( "gradeDocuments", state -> { boolean relevant = state.docs().stream() .anyMatch(d -> d.metadata().get("relevant").equals("yes")); if (relevant) return "generate"; if (state.retryCount() >= 2) return "generate"; // 重试上限 return "rewriteQuery"; }, Map.of( "generate", "generate", "rewriteQuery", "rewriteQuery" ) );这个"评估-改写-重试"的循环,就是Agentic RAG和普通 RAG 的核心区别。普通 RAG 是"一条道走到黑",Agentic RAG 会自我纠错。我实测下来,加了这一层之后,复杂问题的回答准确率提升明显,代价是多花一两次模型调用。
5.3 查询改写节点的实现技巧
查询改写不是简单地把问题换个说法,而是要根据检索失败的原因做针对性调整。常见策略有三种:一是扩展同义词("报销流程"→"报销 流程 步骤 申请"),二是拆解复合问题("A 和 B 的区别"→分别检索 A 和 B),三是补全上下文(多轮对话里把"它"替换成实际指代)。
我用的是让大模型来做改写,提示词大致是:"以下问题在知识库中检索效果不佳,请改写为更适合向量检索的形式,保留核心实体,补充可能的同义词,只输出改写后的问题。"实测这个提示词比"请改写问题"效果好很多,因为给了模型明确的优化目标。
提示:改写节点一定要设重试上限,否则模型可能陷入"改写→检索失败→再改写"的死循环,把 token 烧光。我设的是 2 次。
6. 检索质量优化与常见问题排查
6.1 提升命中率:混合检索与重排序
纯向量检索有个天然缺陷:它对精确匹配不敏感。比如用户问"工单编号 INC-2024-001 的状态",向量检索可能召回一堆"工单处理流程"的文档,却漏掉那条精确记录。解决办法是混合检索:向量检索 + 关键词检索(BM25),两路结果融合。
融合算法我用的是RRF(Reciprocal Rank Fusion,倒数排名融合)。它的逻辑很简单:对每个文档,把它在各路结果中的排名取倒数再求和,得分高的排前面。公式是score = Σ 1/(k + rank),k 通常取 60。
这里有个细节值得说:LangChain4j 和 LangChain 的默认 RRF 实现在去重逻辑上是有差异的。LangChain 的 Python 版按文档 ID 去重,而某些 Java 实现按文档内容哈希去重。如果你的文档块内容有重复(比如页眉页脚被切进多个块),按内容去重会把它们合并成一个,导致排名计算失真。我的做法是入库时给每个块生成唯一 ID 写进 metadata,融合时按 ID 去重,稳得多。
重排序(Rerank)是另一层优化。向量检索召回 Top-20,然后用一个 Cross-Encoder 模型对这 20 个重新打分,取 Top-5 送给大模型。Cross-Encoder 比向量相似度准,但慢,所以只对小候选集用。我用的重排序模型是 BGE-Reranker,本地部署。
| 优化手段 | 提升点 | 代价 |
|---|---|---|
| 混合检索 + RRF | 精确匹配召回率 | 多一路检索开销 |
| Cross-Encoder 重排序 | Top-K 准确率 | 推理延迟增加 |
| 元数据过滤 | 缩小检索范围 | 需提前标注 |
| 查询改写 | 复杂问题召回 | 多一次模型调用 |
6.2 常见问题速查表
下面这张表是我和团队实际遇到并解决的问题,按出现频率排序:
| 问题现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 检索结果完全不相关 | Embedding 模型索引/查询不一致 | 检查两处模型名和维度是否相同 |
| 答案答非所问 | chunk 太大,噪声多 | 减小 chunk 到 300 字,加重叠 |
| 精确问题召回不到 | 纯向量检索不敏感 | 加 BM25 混合检索 |
| 重复内容反复出现 | 切分重叠度过大 | 重叠降到 50 字,检查去重逻辑 |
| 入库速度极慢 | 逐条调用 Embedding | 改批量,批大小 32-64 |
| 内存溢出 | 一次性加载全部文档 | 流式加载,分批 ingest |
| 多轮对话答非所问 | 没做查询改写 | 加改写节点,补全指代 |
| 检索延迟高 | 候选集太大 | 先向量召回 20,再重排取 5 |
6.3 数据一致性:文档更新后索引怎么同步
这是生产环境绕不开的问题。文档改了,索引还是旧的,用户就会拿到过期答案。我的方案是增量索引 + 版本标记:每个文档块入库时带上docId和version,文档更新时先按docId删除旧块,再插入新块。Milvus 支持按标量字段删除,LangChain4j 的EmbeddingStore.removeAll(Filter)能表达这个操作。
注意:删除和插入之间有个时间窗口,如果此时正好有查询进来,可能检索不到该文档。对一致性要求极高的场景,可以用"双写 + 切换":新版本写到新 collection,写完再切流量,旧 collection 延迟删除。
7. 我踩过的坑和几条实在建议
第一个坑是版本兼容。LangChain4j 和 LangGraph4j 的版本要匹配,我一开始用了 LangGraph4j 的最新版配 LangChain4j 的旧版,结果StateGraph里的类型对不上,编译报了一堆泛型错误。后来统一到兼容的版本组合才消停。建议你锁定版本,别用LATEST。
第二个坑是Embedding 服务的并发限制。本地推理服务默认并发数很低,我批量入库时并发一高就超时。解决办法是在客户端加限流,或者把批大小调小、串行处理。别指望服务端能扛住你无限制的并发。
第三个坑是提示词里的文档拼接。检索回来的文档块直接拼进提示词,如果块之间有重复内容,模型会重复回答。我加了一个简单的去重:按内容前 50 字做哈希,重复的只保留一个。另外,给每个块加上来源标注("来源:产品手册第 3 页"),模型回答时能引用出处,用户信任度更高。
第四个坑是评估缺失。一开始我靠"感觉"判断检索好不好,后来发现完全不准。后来我建了一个小测试集:50 个问题 + 标准答案,每次改完参数跑一遍,看命中率和答案准确率的变化。这个习惯帮我避免了好几次"以为优化了其实退步了"的情况。
最后分享一个实用技巧:给检索结果加"相关性分数"。LangChain4j 的EmbeddingStoreContentRetriever返回的Content里带score(),你可以在生成前过滤掉分数低于阈值的块。阈值多少合适?我的经验是向量相似度低于 0.6 的基本可以扔,但具体值要看你用的模型和距离度量方式,建议在测试集上跑一遍确定。
这套系统我目前跑在内部环境,日均查询量几千次,响应时间 P95 在 2 秒左右(含一次重排序和一次生成)。后续我打算把 ontology 那套东西接进来,让知识库不只是"检索文本",而是能理解实体之间的关系,那样对复杂推理类问题的支持会更好。如果你也在做类似的事,欢迎交流踩坑经验。