找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3284

积分

0

好友

440

主题
发表于 3 小时前 | 查看: 5| 回复: 0

业务中 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;

public class 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
public class ParentChildRetriever implements ContentRetriever {

    private final EmbeddingStore<TextSegment> qdrantStore;
    private final EmbeddingModel embeddingModel;
    private final 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 系统优化,是一项极其细致的脏活累活,考验的完全是对非结构化数据治理的细致把控。从最底层的切割策略到上层的检索逻辑,每一步都值得反复推敲。

在云栈社区,我们经常探讨这类工程化落地的技术细节。毕竟,把论文中的概念变成生产环境里稳定运行的代码,中间隔着无数需要亲手填平的沟壑。




上一篇:为什么操作数据库和MinIO可以无视一致性?先传后写的极端务实策略
下一篇:大模型为何不设计记忆功能?技术架构与成本深度解析
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-25 10:35 , Processed in 0.788617 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表