ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

RAG分块实战:用LangChain4j1.19调出精准检索

RAG分块实战:用LangChain4j1.19调出精准检索 你做了 RAG把一堆文档塞进向量库结果用户问什么都答非所问检索出来的片段要么太碎、要么太大、要么语义对不上关键词。这篇文章用 LangChain4j 1.19纯 JDK 21 可运行不依赖 Spring讲透 RAG 的第一道坎——分块Chunking。从为什么分块完全决定了检索质量到 chunk size、overlap、不同分块策略的实测对比最后给出一套可直接落地的最佳实践附完整可运行代码。看完你就能自己动手调出一版查得准、答得对的 RAG。一、这个问题到底是什么RAG检索增强生成Retrieval-Augmented Generation说白了就是开卷考试不靠大模型死记硬背而是先从你的文档库里把相关片段检索出来再让大模型照着这些片段答题。检索出来的片段质量直接决定了答案质量。可现实中检索这一步经常烂到离谱。最常见的症状有三个片段太碎一篇文章被切成几百个小块每个块只讲半句话语义被拦腰切断检索出来的内容根本不成句。片段太大整段整段地塞进去一个 chunk 几千字既浪费大模型的上下文窗口又容易把无关内容裹进来反而干扰答案。切点不对切块位置硬生生切在句子里、甚至切在概念中间一个完整的什么是 X被劈成两半检索时怎么都搜不到关键信息。根子就一个问题分块策略没调对。Chunking 是 RAG 管线的第一环前面的分块质量决定了后面向量检索和生成的天花板。很多人花大精力调 prompt、换大模型却忽略了最基础、性价比最高的分块调优。这篇文章就解决三件事分块为什么这么关键、LangChain4j 里怎么控制分块、以及一套实测有效的参数和策略组合。目标让你看完就能在自己的文档上复现并把搜索不到、答非所问的问题压下去。二、底层原理到底怎么回事要理解分块得先搞懂检索链路里向量是怎么工作的。2.1 一条 RAG 检索链路一个最简 RAG 流程是这样走的切块把长文档按策略切成一个个小块chunk。向量化把每个 chunk 喂给 Embedding 模型把文字变成一串数字向量语义相近的文字向量距离近存进向量库。检索用户提问 → 把问题也转成向量 → 在向量库找距离最近的 N 个 chunk。生成把这 N 个 chunk 拼进 prompt交给大模型回答。其中第 1 步的切块直白地决定了第 3 步检索能命中什么。用类比讲向量检索就像按含义找书。你切块切得好相当于图书馆里每本书都主题明确、装订整齐找起来又快又准切得烂就是书页散落一地、字句混杂找半天找不对。2.2 Embedding 模型的语义胶囊局限这里有个关键前提Embedding 模型把文字转成向量的能力是有上限的。业界通用的向量编码模型比如 OpenAI 的 text-embedding-3、开源的 BGE 系列训练时通常针对几百 token 左右的文本。输入太长语义信息被平均稀释一个 chunk 里 80% 是无关内容那 20% 的关键信息就被淹没向量不像原文。输入太碎语义上下文不全向量表达不完整检索时匹配不上。所以 chunk 太小或太大向量化环节都会失真。分块的本质就是给你手里的文档找到语义尽量完整、体积又别超限的切割粒度。2.3 chunk size 与 overlap 这对核心参数LangChain4j 里最常用的分块器是TextSegmentTransformer配合DocumentSplitter。两个核心参数Chunk size分块大小每个 chunk 最多多少字符或 token。决定每块的容量。Overlap重叠相邻两个 chunk 之间重叠多少字符。决定语义连续性。为什么要重叠因为切块是硬切的一句话或一个概念可能恰好被切在中间前后各剩一半。overlap 让相邻块有一部分重复内容等于给被切断的语义留了缓冲让关键信息至少完整地出现在某一个 chunk 里。2.4 分块粒度怎么选分块粒度要跟你的检索单位匹配用户问的是细粒度问题“XX 方法怎么调”chunk 就适当小问的是主题级问题“整体方案是什么”chunk 可以大一些。没有万能参数但有一条实践铁律跟着语义边界切而不是盲目按字数切。LangChain4j 提供了不同的DocumentSplitter实现切分策略不同分块器切分方式适用场景DocumentByParagraphSplitter按段落切常规文档段落语义完整DocumentBySentenceSplitter按句子切chunk 需要很小时DocumentByWordSplitter按词切英文为主、需精确控制DocumentByCharacterSplitter按字符硬切最朴素的兜底DocumentByRegexSplitter按正则切有固定标记如 ##、章节号按段落切通常是最优起点因为自然段天然是一个完整语义的单元。理解了这个下面的代码就好懂了。三、实战手把手写代码这一节用纯 JDK 21 LangChain4j 1.19.0无 Spring写一个完整的、可直接运行的 RAG 分块实验。我们把不同分块参数和不同分块器跑出来对比看检索到底差在哪。版本说明实查 Maven Centraldev.langchain4j:langchain4j:1.19.0dev.langchain4j:langchain4j-open-ai:1.19.0JDK 21所有版本均为 GA。Embedding 与对话模型用 OpenAI 的TEXT_EMBEDDING_3_SMALL和GPT_4O_MINI需要OPENAI_API_KEY环境变量。3.1 工程骨架pom 与入口先建一个 Maven 工程pom 里就三个依赖core、open-ai、JUnit跑实验。?xml version1.0 encodingUTF-8?project xmlnshttp://maven.apache.org/POM/4.0.0xmlns:xsihttp://www.w3.org/2001/XMLSchema-instancexsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsdmodelVersion4.0.0/modelVersiongroupIdcom.example/groupIdartifactIdrag-chunking/artifactIdversion1.0-SNAPSHOT/versionpackagingjar/packagingpropertiesmaven.compiler.source21/maven.compiler.sourcemaven.compiler.target21/maven.compiler.targetproject.build.sourceEncodingUTF-8/project.build.sourceEncodinglangchain4j.version1.19.0/langchain4j.version/propertiesdependencies!-- LangChain4j 核心Document、Splitter、EmbeddingStore 等都在这里 --dependencygroupIddev.langchain4j/groupIdartifactIdlangchain4j/artifactIdversion{langchain4j.version}/version/dependencydependencygroupIdorg.junit.jupiter/groupIdartifactIdjunit-jupiter/artifactIdversion5.10.2/versionscopetest/scope/dependency/dependencies/project这段 pom 声明了两个 LangChain4j 依赖langchain4j是核心库分块器、向量存储抽象都在里面langchain4j-open-ai是 OpenAI 的官方实现提供真正常用的 Embedding 和 Chat 模型客户端。版本统一用 1.19.0这是写这篇文章时查 Maven Central 确认的最新 GA 版本。3.2 准备实验文档与分块工具类先造一段模拟技术文档的文本故意让它结构复杂有标题、有列表、有长段落这样不同分块策略的差异才看得出来。再写一个工具方法把分块器 参数统一封装供对比实验复用。package com.example.rag;import dev.langchain4j.data.document.Document;import dev.langchain4j.data.document.DocumentSplitter;import dev.langchain4j.data.document.Metadata;import dev.langchain4j.data.document.splitter.DocumentByParagraphSplitter;import dev.langchain4j.data.document.splitter.DocumentBySentenceSplitter;import dev.langchain4j.data.document.splitter.DocumentByWordSplitter;import dev.langchain4j.data.segment.TextSegment;import java.util.List;/*** 分块实验的公共工具* 1. buildSampleDocument() 造一段结构化的模拟文档* 2. split() 用指定的分块器把文档切成 TextSegment 列表* 3. report() 打印每个 chunk 的字符数和前 40 个字符方便肉眼看切得合不合理*/public class ChunkingTool {/** 造一段模拟的产品使用文档故意混入标题、列表和长段落 */public static Document buildSampleDocument() {String content 数据库连接池优化指南什么是连接池数据库连接池是管理数据库连接的一组对象连接是很昂贵的资源创建和销毁都要花费时间。Java 里的连接池常见有 HikariCP、Druid、c3p0。其中 HikariCP 以高性能著称被 Spring Boot 默认使用。HikariCP 的核心参数maximumPoolSize 表示连接池的最大连接数。minimumIdle 表示池里最少保持的空闲连接数。connectionTimeout 表示获取连接的超时时间单位毫秒。idleTimeout 表示空闲连接被回收前可存活的时间。调优建议生产环境的 maximumPoolSize 通常不建议设置得过大过大会浪费数据库资源。经验值一般从 10 起步再根据并发压测结果往上调。连接池并不是越大越好因为数据库本身有连接上限过多连接反而会互相争抢 CPU 和内存。;// Document 是 LangChain4j 的数据模型第二个参数是元数据这里留空return Document.from(content, Metadata.from(source, docs/connection-pool.md));}/*** 使用指定的分块器切分文档。** param splitter 分块器实例不同的分块策略传不同实现* return 切出来的 chunkTextSegment列表*/public static ListTextSegment split(DocumentSplitter splitter, Document doc) {// DocumentSplitter.split() 返回的是 ListTextSegmentTextSegment 就是一个带元数据的文本块return splitter.split(doc);}/** 打印每个 chunk 的信息方便对比不同策略的切片效果 */public static void report(ListTextSegment segments) {System.out.println(切出的 chunk 总数: segments.size());for (int i 0; i segments.size(); i) {TextSegment seg segments.get(i);String head seg.text().lines().findFirst().orElse();System.out.printf( [#%d] %d字符 | 开头: %s%n, i, seg.text().length(), head);}System.out.println();}/** 常用分块器工厂方便在实验里快速切换 */public static DocumentSplitter paragraphSplitter(int maxChunkChars, int overlapChars) {// DocumentByParagraphSplitter: 按段落切maxSegmentSizeInChars 是每块上限字符数return new DocumentByParagraphSplitter(maxChunkChars, overlapChars);}public static DocumentSplitter sentenceSplitter(int maxChunkChars, int overlapChars) {return new DocumentBySentenceSplitter(maxChunkChars, overlapChars);}public static DocumentSplitter wordSplitter(int maxChunkChars, int overlapChars) {return new DocumentByWordSplitter(maxChunkChars, overlapChars);}}这段代码先定义了一个数据准备工具buildSampleDocument()造了一段模拟文档里面既有标题也有段落和列表方便暴露硬切的毛病三个xxxSplitter()方法返回不同策略的分块器report()把每个 chunk 的开头打印出来这样你能肉眼看出来按段落切和按句子切出来的块长什么样。代码里的TextSegment就是 LangChain4j 里一个带元数据的文本块的数据结构后续向量化存库的就是它。3.3 实验三种分块策略的切片效果对比现在写一个测试把三种分块器在同一条文档上跑一遍看各切出多少个 chunk、每块长什么样。package com.example.rag;import dev.langchain4j.data.document.Document;import dev.langchain4j.data.document.DocumentSplitter;import dev.langchain4j.data.segment.TextSegment;import org.junit.jupiter.api.Test;import java.util.List;import static org.junit.jupiter.api.Assertions.assertTrue;/*** 分块策略对比实验* 用相同的文档、相同的每块上限 120 字符 / 重叠 20 字符* 分别用 按段落、按句子、按词 三种策略切观察结果差异。*/class ChunkingCompareTest {Testvoid compareSplitters() {Document doc ChunkingTool.buildSampleDocument();// 统一参数每块最多 120 字符相邻块重叠 20 字符方便横向对比int maxChars 120;int overlap 20;System.out.println( 1) 按段落切 (DocumentByParagraphSplitter) );DocumentSplitter byParagraph ChunkingTool.paragraphSplitter(maxChars, overlap);ListTextSegment paraSegs ChunkingTool.split(byParagraph, doc);ChunkingTool.report(paraSegs);System.out.println( 2) 按句子切 (DocumentBySentenceSplitter) );DocumentSplitter bySentence ChunkingTool.sentenceSplitter(maxChars, overlap);ListTextSegment sentenceSegs ChunkingTool.split(bySentence, doc);ChunkingTool.report(sentenceSegs);System.out.println( 3) 按词切 (DocumentByWordSplitter) );DocumentSplitter byWord ChunkingTool.wordSplitter(maxChars, overlap);ListTextSegment wordSegs ChunkingTool.split(byWord, doc);ChunkingTool.report(wordSegs);// 三个策略都至少能切出东西实验才算有效assertTrue(!paraSegs.isEmpty() !sentenceSegs.isEmpty() !wordSegs.isEmpty());}}这个测试在同一段文档、同一组参数下把三种分块器各跑一遍并打印结果。跑完你会看到按段落切出来的块每块基本是一整段完整的话语义完整按句子切会细很多可能把一个论证拆成好几块按词切则可能把一句话劈成几截。这个差异就是后面检索质量差异的根源——语义完整的 chunk 才检索得准。3.4 实验把分块结果真正喂进 RAG 检索切片对比只是观感要证明语义完整的 chunk 检索更准得把分块结果真正向量化、检索一遍看命中。下面的测试把两种分块结果分别建库用同一个问题去检索对比各自命中的片段。package com.example.rag;import dev.langchain4j.data.document.Document;import dev.langchain4j.data.document.splitter.DocumentByParagraphSplitter;import dev.langchain4j.data.segment.TextSegment;import dev.langchain4j.embedding.Embedding;import dev.langchain4j.embedding.EmbeddingModel;import dev.langchain4j.model.openai.OpenAiEmbeddingModel;import dev.langchain4j.model.openai.OpenAiEmbeddingModelName;import dev.langchain4j.store.embedding.EmbeddingMatch;import dev.langchain4j.store.embedding.EmbeddingSearchRequest;import dev.langchain4j.store.embedding.EmbeddingSearchResult;import dev.langchain4j.store.embedding.EmbeddingStore;import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore;import org.junit.jupiter.api.Test;import java.util.List;import static org.junit.jupiter.api.Assertions.assertEquals;/*** 端到端小实验同样是每块 200 字符、重叠 30 字符* 用 paragraph按段落和 word按词两种分块分别建向量库* 用同一个问题检索对比命中的片段是否是关键信息完整的那块。*/class RagRetrievalTest {Testvoid compareRetrievalByChunking() {// 1. 建 Embedding 模型把文本转成向量EmbeddingModel embeddingModel OpenAiEmbeddingModel.builder().apiKey(System.getenv(OPENAI_API_KEY)).modelName(OpenAiEmbeddingModelName.TEXT_EMBEDDING_3_SMALL).build();Document doc ChunkingTool.buildSampleDocument();// 2. 用按段落分块建库ListTextSegment paraSegs ChunkingTool.split(ChunkingTool.paragraphSplitter(200, 30), doc);EmbeddingStoreTextSegment paraStore index(embeddingModel, paraSegs);// 3. 用按词分块建库把每块上限调小模拟切太碎ListTextSegment wordSegs ChunkingTool.split(ChunkingTool.wordSplitter(80, 10), doc);EmbeddingStoreTextSegment wordStore index(embeddingModel, wordSegs);// 4. 同一个问题检索String question HikariCP 的 maximumPoolSize 参数怎么调;System.out.println(—— 按段落切分后的检索结果 ——);EmbeddingMatchTextSegment paraBest searchBest(embeddingModel, paraStore, question);System.out.println(命中片段: paraBest.embedded().text());System.out.println(\n—— 按词切碎后的检索结果 ——);EmbeddingMatchTextSegment wordBest searchBest(embeddingModel, wordStore, question);System.out.println(命中片段: wordBest.embedded().text());// 结论断言语义完整的 chunk 切法命中的内容应包含maximumPoolSize 表示...这个关键信息assertEquals(true, paraBest.embedded().text().contains(maximumPoolSize 表示),按段落切应命中包含参数定义的完整片段);}/** 把一批 TextSegment 全部向量化并存入内存向量库返回建好的 store */private static EmbeddingStoreTextSegment index(EmbeddingModel model, ListTextSegment segs) {// InMemoryEmbeddingStore 是不用外部数据库的内存向量库适合实验EmbeddingStoreTextSegment store new InMemoryEmbeddingStore();for (TextSegment seg : segs) {Embedding emb model.embed(seg.text()).content();store.add(emb, seg);}return store;}/** 用问题向量去库里检索返回相似度最高的 1 条 */private static EmbeddingMatchTextSegment searchBest(EmbeddingModel model, EmbeddingStoreTextSegment store, String question) {Embedding questionEmb model.embed(question).content();EmbeddingSearchRequest request EmbeddingSearchRequest.builder().queryEmbedding(questionEmb).maxResults(1).build();EmbeddingSearchResultTextSegment result store.search(request);ListEmbeddingMatchTextSegment matches result.matches();return matches.get(0);}}这段代码是整篇文章的压轴实验它把分块和检索真正串起来先用OpenAiEmbeddingModel把文字变向量的模型建好然后同一份文档分别用按段落和按词切碎两种方式建出两个内存向量库InMemoryEmbeddingStore是不用外部数据库的向量库实验够用再用同一个问题去搜。关键看paraBest和wordBest命中的片段按段落切通常能命中maximumPoolSize 表示连接池的最大连接数这句完整定义而切得太碎时可能只捞到半句话。这直接证明了 chunk 语义完整度决定检索命中质量。3.5 补充为什么不用 Spring 也能跑你可能会问不是有langchain4j-spring-boot-starter吗这里说明一下写这篇文章时实查 Maven CentralLangChain4j 1.19.0 这个 GA 版本里Spring Boot 4 的 starterlangchain4j-*-spring-boot4-starter还没发布到 1.19.0实查返回 404而旧的 Boot 3 starter 停在 0.36.2。所以为了保证下载即运行、全部用 GA 版本这篇文章特意用纯langchain4j核心库 普通 Java 跑。业务上要用 Spring 时等对应 starter 发布后代码里的分块和检索逻辑完全通用只是装配方式换成Bean而已。四、踩坑经验和最佳实践4.1 最常见的四个坑坑一chunk 上限设得太小或太大都没意识到。很多人默认照搬网上 512 或 1024。但 token 和字符不是一回事中文一个字符基本一个字英文一个 token 约 4 个字符。分块参数单位如果没看清LangChain4j 这些 splitter 用的是字符新手容易把上限设成 100 字符结果中文每块只有一两句话语义被割碎。对策先做切片观感检查——把分块结果打印出来人眼看一遍别急着建库。像本文report()那样看每块开头一眼就知道切得合不合理。坑二按固定字符数硬切把语义切断了。用DocumentByCharacterSplitter最省事但最容易把一句话、一个概念劈两半。对策优先用按段落/按句子的分块器让切点落在自然语义边界上。有固定章节标记的文档用DocumentByRegexSplitter按##标题切更稳。坑三完全没有 overlap。相邻块之间没有重叠被恰好切在边界的句子就丢了检索时怎么都搜不到。对策overlap 一般给 chunk size 的 10%~20%。比如 chunk 200 字符overlap 设 20~40 字符就够别设太大浪费存储和 token。坑四把整篇大文档塞进一个 chunk。超过 Embedding 模型的输入上限text-embedding-3-small 是 8191 token要么报错要么被截断要么语义被稀释。对策分块上限必须明显小于模型的输入限制留出余量。4.2 一套实测有效的起步参数没有万能值但下面这套从大量项目里总结的组合可以作为安全起点再按你的文档微调文档类型推荐的 chunk 策略chunk size字符overlap技术文档 / 手册段落清晰按段落切300~60030~60问答对 / 短知识条目按句子切100~25010~25长篇小说 / 散文无固定结构按段落切 稍大500~80050~80带 ## 章节的 markdown按正则##切按章节章节间少量核心判断标准就一条切出来的每个 chunk单独拿出来读能不能让一个不了解上下文的人看懂它讲什么。能就是好分块。4.3 分块要跟 Embedding 模型匹配不同 Embedding 模型对多少 token 效果最好有不同偏好。BGE、text-embedding-3 这类主流模型通常对 256~512 token 的文本表示最稳定语义不稀释也不缺上下文。换算到中文约 200~500 字。所以chunk 控制在 200~600 字符是比较稳的区间——太大超模型舒适区太小缺上下文。4.4 检索质量不能只靠分块分块是地基但别指望它解决所有问题。检索质量还受这些影响且优先级不低Embedding 模型选择领域差异大法律、医疗时通用模型不如领域微调模型。多路召回关键词检索BM25 向量检索混合比单用向量召回率更高这属于另一篇文章可关注后续。Top-K 与分数阈值只取前 3~5 个、并过滤掉相似度过低的能显著减少无关信息进 prompt。重排序Rerank召回 20 个用重排模型精排后只取 3 个精度最高需要专门的 rerank 模型。分块做对了是从 60 分到 80 分的跃升上面这几项是往 90 分以上走的下一步。五、性能对比和技术选型维度按段落切推荐按句子切按字符硬切检索精度高语义完整中可能缺上下文低易切断语义检索耗时中中中chunk 多时略高存储量中高chunk 多高实现成本低低最低典型场景技术文档、wiki、手册短问答、FAQ无结构兜底结论绝大多数场景直接选按段落切chunk 200~600 字符、overlap 10%~20%作为默认配置。只有当你的文档本身是短问答对每个条目就是完整语义时才考虑按句子切。按字符硬切基本不推荐单独用只作为文档结构完全没法识别时的兜底。关于向量库选型本文实验用了InMemoryEmbeddingStore内存库零部署适合本地实验和测试。生产环境应根据数据量换正式向量库数据量小百万条内可用 Redis 的向量搜索langchain4j-redis海量数据或需要混合检索Milvuslangchain4j-milvus更合适。分块逻辑与向量库解耦切换只改 store 的实例化代码。六、总结分块Chunking是 RAG 被低估的第一道工序它直接决定向量检索能命中什么进而决定大模型答得准不准。这篇文章把三件事讲透了原理分块的粒度必须匹配 Embedding 模型的语义上限chunk 太小缺上下文、太大语义被稀释overlap 给被切断的语义留缓冲。实践用纯 JDK 21 LangChain4j 1.19.0 写了三种分块器的切片对比以及一个真正分块 → 向量化 → 检索的端到端实验验证了语义完整的 chunk 检索更准。落地给出安全起步参数按段落切、chunk 200~600 字符、overlap 10%~20%和四个高频坑的解法。一句话结论调 RAG 检索质量先看分块——让每个 chunk 单独拿出来读起来都是完整的一句话。分块这块地基打好了再去谈后面 Embedding 选型、混合检索和重排序才有意义。动手建议拿你自己的一份文档用本文的report()方法把不同 chunk size 的切片打印出来看一遍从中选每块读起来最完整的那组参数再跑端到端检索对比命中率。分块调优的性价比往往比你换一个大模型还高。
返回列表