⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 mp.weixin.qq.com/s?__biz=MzU4MDUyMDQyNQ==&mid=2247569857&idx=2&sn=65a020e66120201ba410e4d487b40a27&poc_token=HIPp_mmj3VBJRAZzi-n_qvnAgk1SrW1_f60apvxr 「null」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

业务中 RAG 召回率高不高,其实数据源头就占了很大原因,数据切片 Chunking 的质量,决定了整个系统召回率的上限,而用的各种昂贵大模型和神级 Prompt,仅仅是在无限逼近这个上限而已。

如果面试一个 AI 相关的后端研发,被问到文档怎么切分,要是敢回答“按 500 个字符截取一下”,面试官基本会认为你只做过玩具 Demo。

不要暴力定长切分

新手刚搭 RAG 的时候,最喜欢用 Fixed-size Chunking 定长切分,比如代码里写死每 500 个字切一块。

这种切法的痛点极其明显:语义极其容易被物理腰斩。

设想你正在处理一份复杂的法考案例题或者业务合同,一段极其关键的因果逻辑,刚好横跨了第 499 到 505 个字符。切分器无情地一刀劈下去,前半句留在了 Chunk A,后半句分到了 Chunk B。

这两块残缺的文本分别扔给 Embedding 模型去算向量,原本完整的语义裂开了。用户提问,无论是匹配前半句的特征还是后半句的特征,召回引擎都大概率找不到这块被破坏的文本,召回率肯定是不高的。

三阶语义切片落地方案

在实际业务中,做语义切片 Semantic Chunking 是一套层层递进的,我们直接上干货和代码。

方案一:基于标点符号的递归切分

这是目前最常用,也是性价比最高的基础方案。

核心逻辑是,绝不直接按死板的字数切,而是顺应自然语言的“呼吸节奏”来切。

我们会设定一个降级递归的规则:先尝试按双换行符(\n\n,通常是段落)切分;如果切出来的段落依然超长,退而求其次按单换行符(\n)切;如果还超长,按句号(。)切;实在不行最后才按逗号切。这种做法能最大程度保全最基础的业务语义。

方案二:引入重叠窗口

即便用了递归切分,也难免会在长文本边界出现上下文割裂。这时候就需要设置一个 10% 到 20% 的重叠区,比如 Chunk 2 的开头,实际上是 Chunk 1 的末尾,用冗余的方式强行维持语境连贯。

新手喜欢自己写 substring 截取字符串,这绝对是个坑。大模型的限制是 Token,中文的 500 个字符可能对应 300 个 Token,也可能对应 600 个 Token。必须注入与模型一致的分词器 Tokenizer 进行精准切分。

用 LangChain4j 实现非常简单:

import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.DocumentSplitter;
import dev.langchain4j.data.document.splitter.DocumentSplitters;
import dev.langchain4j.model.openai.OpenAiTokenizer;

publicclass DocumentProcessService {

public List<TextSegment> processWithOverlap(Document document) {
// 1. 定义分词器 (这里以 OpenAI 为例,私有化部署可以用 HuggingFace 的分词器)
Tokenizer tokenizer = new OpenAiTokenizer("gpt-4");

// 2. 创建带有重叠的递归切分器
int maxTokens = 500; // 每个 Chunk 最大 500 Token
int overlapTokens = 50; // 相邻 Chunk 之间重叠 50 Token (约 10%)

DocumentSplitter splitter = DocumentSplitters.recursive(
maxTokens,
overlapTokens,
tokenizer
);

// 3. 执行切分,框架会自动处理递归降级和重叠部分的计算逻辑
return splitter.split(document);
}
}

方案三:父子文档语义映射

我们做检索经常会陷入一个两难的困境:切得太长,向量特征失焦,查不准;切得太短,查得确实准,但喂给大模型时缺乏上下文,模型开始瞎编。

解决办法:小切片负责召回,大段落负责喂给大模型。

  • 写入时(入库): 大段落 Parent 存入 Redis,小段落 Child 进行 Embedding 存入 Qdrant 向量库,并在 Qdrant 的 Payload(元数据)里记录 Redis 的 Key(parent_id)。
  • 读取时(检索): 查 Qdrant 拿到小段落的 parent_id,去 Redis 里把大段落捞出来,拼装好再喂给大模型。

1. 数据入库阶段 (Ingestion) 的核心代码:

public void ingestParentChild(String largeText) {
// 1. 先切出大段落 (父文档) - 比如按双换行符切分段落
List<String> parentChunks = splitIntoParagraphs(largeText);

for (String parentText : parentChunks) {
// 生成该大段落唯一的 parent_id
String parentId = UUID.randomUUID().toString();

// 2. 将完整的父文档存入 KV 存储 (Redis)
redisTemplate.opsForValue().set("doc:parent:" + parentId, parentText);

// 3. 将父文档进一步切成极短的小句子 (子文档)
List<String> childChunks = splitIntoSentences(parentText);

List<TextSegment> childSegments = new ArrayList<>();
for (String childText : childChunks) {
// 4. 【灵魂操作】将 parent_id 塞入子文档的 Metadata (元数据)
Metadata metadata = new Metadata();
metadata.put("parent_id", parentId);
childSegments.add(TextSegment.from(childText, metadata));
}

// 5. 对子文档进行 Embedding 并存入 Qdrant 向量库
embeddingStore.addAll(embeddingModel.embedAll(childSegments).content(), childSegments);
}
}

2. 自定义检索阶段 (Custom Retriever) 的核心代码:

要想让业务主链路用上这套机制,必须重写 LangChain4j 的 ContentRetriever 接口。

@Component
@RequiredArgsConstructor
publicclass ParentChildRetriever implements ContentRetriever {

privatefinal EmbeddingStore<TextSegment> qdrantStore;
privatefinal EmbeddingModel embeddingModel;
privatefinal StringRedisTemplate redisTemplate;

@Override
public List<Content> retrieve(Query query) {
// 1. 将用户问题转为向量
Embedding queryEmbedding = embeddingModel.embed(query.text()).content();

// 2. 去 Qdrant 中精准检索最相似的“小句子 (Child Chunks)” (比如取 Top 5)
List<EmbeddingMatch<TextSegment>> matches = qdrantStore.findRelevant(queryEmbedding, 5);

// 3. 提取命中句子的 parent_id,并进行【去重】 (因为有可能命中同一个父段落里的两句话)
Set<String> parentIds = matches.stream()
.map(match -> match.embedded().metadata().getString("parent_id"))
.collect(Collectors.toSet());

// 4. 拿着 ID 去 Redis 中批量捞出完整的大段落 (Parent Chunks)
List<Content> finalContents = new ArrayList<>();
for (String parentId : parentIds) {
String parentText = redisTemplate.opsForValue().get("doc:parent:" + parentId);
if (parentText != null) {
// 组装成最终的 Content 返回
finalContents.add(Content.from(parentText));
}
}

// 5. 此时大模型拿到的是极其精准且拥有完整上下文的大段落!
return finalContents;
}
}

这套代码逻辑弄下来,RAG召回率还是可以提升不少的。

元数据注入

做完了上面的切分和召回,数据流水线上还有极其重要的一步:防止切片变成失去全局语境的垃圾数据。

举个例子,经过切分后,有这么一个切片:“张三被判处有期徒刑三年”。大模型拿到这句话,根本不知道这是几几年的案子、什么犯罪类型。

正确的做法是在数据抽取清洗环节,比如用 Apache NiFi 处理 PDF 时),顺手提取出当前文档的标题、章节名甚至页码。然后,把这些全局上下文强行拼在切片的前面,或者存进 Metadata 里。最终存入向量引擎的文本变成了:

[《2023年刑法经典案例》 - 抢劫罪章节 - 第12页] 张三被判处有期徒刑三年。

经过这一步处理,这个 Chunk 就在物理层面拥有了绝对完整的全局语义。

谁在最后

真正的 RAG 系统优化,是一项极其细致的脏活累活,考验的全是对非结构化数据治理的细致把控。

文章目录
  1. 1. 不要暴力定长切分
  2. 2. 三阶语义切片落地方案
    1. 2.1. 方案一:基于标点符号的递归切分
    2. 2.2. 方案二:引入重叠窗口
    3. 2.3. 方案三:父子文档语义映射
  3. 3. 元数据注入
  4. 4. 谁在最后