LLM-Rag原理解析_递归

LLM-Rag原理解析_List_02

1. 文档处理

开发RAG系统的第一步是准备文档,这些文档将作为RAG系统的基础输入数据。

LLM-Rag原理解析_分块_03

2. OCR文本提取

接下来,文档由 OCR(图片转文本)模型进行处理。如果需要,该模型会提取文本。

LLM-Rag原理解析_递归_04

3. 文本拆分

文本被分成更小的、易于管理的部分。这种分块可以在后期进行更有效的处理和分析。

具体的分块策略,后面会详细讲解,可以根据业务需求自定义N种分块模式

LLM-Rag原理解析_递归_05

4. 文本嵌入

然后每个文本块都会通过嵌入模型。该模型将块转换为向量,即捕获文本语义的数字表示。

LLM-Rag原理解析_递归_06

5. 向量存储

上一步将文本转换为向量数据后,需要存储到向量数据库中(例如PgVector),该数据库允许系统根据语义相似性有效地检索相关信息。

LLM-Rag原理解析_List_07

6. 输入问题并检索

用户向系统输入问题,该问题将用于从矢量数据库中检索最相关的信息(其实就是从向量库中采用余弦相似度计算匹配相似的数据)。

余弦相似度计算 在向量空间中,文本被映射为高维向量(如1536维度)。余弦相似度通过计算两个向量夹角的余弦值来衡量相似性:

LLM-Rag原理解析_分块_08

夹角越小(余弦值越接近1),语义相似度越高

与传统关键词检索的区别 传统方法(如BM25)依赖关键词匹配,无法处理同义词或语义关联。向量相似度则通过语义嵌入(Embedding)捕捉上下文关联,例如“量子计算”和“量子比特研究”即使无重叠词汇也能匹配

LLM-Rag原理解析_递归_09

7. 输入嵌入

接下来需要将用户输入的问题转换成相同的向量纬度,只有转换成和文档相同的向量纬度,确保了问题和文本块都位于同一向量空间中,才能从向量数据库中匹配到相似的数据

LLM-Rag原理解析_List_10

8. 向量匹配

同上,将嵌入后的问题在向量存储库中检索匹配相似的数据

LLM-Rag原理解析_递归_11

9. 数据处理

从向量库中匹配到相似的数据后,系统将交由LLM 处理相关信息以对用户的问题制定详细的答案。

LLM-Rag原理解析_递归_12

10. 数据呈现

最终,LLM将针对用户的问题,并结合向量库中匹配到的相似的数据分析,输出最终的语义化文本内容给用户

LLM-Rag原理解析_递归_13

11. 大模型RAG中的分块策略

分块策略在检索增强生成(RAG)方法中起着至关重要的作用,它使文档能够被划分为可管理的部分,同时保持上下文。每种方法都有其特定的优势,适用于特定的用例。

将大型数据文件拆分为更易于管理的段是提高LLM应用效率的最关键步骤之一。目标是向LLM提供完成特定任务所需的确切信息,不多也不少。

“我的解决方案中应该采用何种合适的分块策略”是LLM实践者在构建高级 RAG 解决方案时必须做出的初始和基本决策之一

参考文献:

 https://medium.com/@danushidk507/chunking-strategies-f93dbdec7634

12. 什么是分块?

分块涉及将文本划分为可管理的单元或“块”,以实现高效处理。这种分割对于语义搜索、信息检索和生成式 AI 应用等任务至关重要。每个块都保留上下文和语义完整性,以确保结果连贯

13. 分块技术及其策略

各种分块技术根据文本结构和应用需求满足特定需求:

  • 固定长度分块:根据标记、单词或字符将文本分割成统一的大小。这种方法计算效率高,但可能在边界处切断有意义的上下文。
  • 基于句子的分块:按句子分割文本,保留语法和上下文完整性。非常适合对话模型,但可能对较长的文本效率不高。
  • 基于段落的分块:按段落分组文本,保持主题上下文。适用于结构化文档,但可能对精细调整的任务失去粒度。
  • 语义分块:专注于按意义分组文本,而不是结构。这确保了语义连贯性,但增加了计算开销,因为它需要深入的语言理解。(手动分块,LLM协助分块)
  • 滑动窗口分块:使用重叠窗口对文本进行分段,减少块边界处的信息损失。它确保更好的上下文保留,但会增加内存和处理成本。
  • 文档分块:将整个文档视为一个单一块。这种方法对于保持整体上下文有效,但由于内存限制,可能不适用于大型文本。

14. 分块优化关键策略

为了最大化分块的优势,采用以下策略:

  • 重叠块:包括块之间的某些重叠可以确保在段落之间不会丢失关键信息。这对于需要无缝过渡的任务尤其重要,如对话生成或摘要。
  • 动态块大小:根据模型的容量或文本的复杂性调整块大小可以提升性能。较小的块适合 BERT 等模型,而较大的块适用于需要更广泛上下文的生成任务。
  • :递归或多级分块允许处理复杂的文本结构,例如将文档拆分为章节、节和段落。
  • 向量化的对齐:分块技术的选择对检索系统中的向量表示有显著影响。句子转换器和 BERT 或 GPT 等嵌入通常用于与分块粒度对齐的最佳向量化

15. 优点与局限性

  • 好处:
  • 增强上下文理解。
  • 支持 RAG 系统中高效的索引和检索。
  • 保持生成模型中更好的准确性,语义连贯性。
  • 限制:
  • 计算上对语义和重叠分块较为昂贵。
  • 需要调整以平衡上下文保留和处理效率。

16. 应用场景

分词在以下方面被广泛使用:

  • 检索系统:在搜索引擎或聊天机器人中检索回答查询的相关片段。
  • 生成模型:保持文本生成的上下文连贯。
  • 学术和法律研究:确保对结构化和复杂文档进行详细、有意义的分段。

通过采用适当的分块策略,从业者可以提升检索和生成系统的性能,在计算资源和上下文准确性之间取得平衡

17. 各种分块策略详细说明

a. 固定长度分块

固定长度分块将文本分割成指定字符数或词数的块。这种方法简单直接,但往往存在将有意义的内容分割开的风险,导致上下文丢失。

如何工作:

  • 该方法根据预定义的长度(例如,单词数、标记或字符数)将文本划分为均匀的块。
  • 例如,一个包含 100 个单词的段落可能被分成十个 10 个单词的片段。

优势:

  • 简洁性与计算效率。
  • 适用于结构化文本或当上下文边界不是关键时。

缺点:

  • 可能会在块之间分割句子或想法,导致语义连贯性丧失。

示例:

  • 文本分块通过将文本分解成部分来提高检索。
  • 固定长度分块(每块 10 个字符):
  • 块 1:“Chunking i”
  • 块 2:“提高 re”
  • 块 3:“通过”检索

代码:

public static List<String> adaptiveChunk(String text, String modelPath) throws IOException {
        // 1. 加载预训练的分句模型
        try (FileInputStream modelIn = new FileInputStream(modelPath)) {
            SentenceModel model = new SentenceModel(modelIn);
            SentenceDetectorME detector = new SentenceDetectorME(model);

            // 2. 检测句子并分块
            String[] sentences = detector.sentDetect(text);
            List<String> chunks = new ArrayList<>();
            for (String sentence : sentences) {
                chunks.add(sentence.trim());
            }
            return chunks;
        }
    }
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.

b. 语义块切分

文本根据语义连贯性分成块,确保每个块都是一个有意义的单元。这通常需要使用嵌入来找到逻辑边界。

如何工作:

  • 文本根据语义连贯性而非固定大小进行划分。
  • 使用自然语言理解(NLP)来识别逻辑断点,如句子或主题边界。

优势:

  • 保留每个片段的意义和上下文。
  • 提升检索增强生成(RAG)任务的准确性。

缺点:

  • 计算成本高,因为它需要语义解析。

示例:

  • AI 研究涵盖各种主题。机器学习专注于模式。
  • 语义块:
  • 块 1:“人工智能研究涵盖各种主题。”
  • 机器学习专注于模式。
/**
     * 实现基于语义的文本分块,保留句子完整性
     * @param text 原始文本
     * @param maxTokens 最大token数(按字符近似计算)
     * @return 语义完整的文本块列表
     */
    public static List<String> semanticChunk(String text, int maxTokens) {
        List<String> chunks = new ArrayList<>();
        if (text == null || text.isEmpty()) return chunks;
        
        // 1. 按句子边界初步分割
        String[] sentences = text.split("\\. ");
        StringBuilder currentChunk = new StringBuilder();
        
        // 2. 动态合并语义单元
        for (String sentence : sentences) {
            // 计算合并后的潜在长度(包含分隔符)
            int potentialLength = currentChunk.length() + sentence.length() + 2; 
            if (potentialLength <= maxTokens) {
                currentChunk.append(sentence).append(". ");
            } else {
                if (!currentChunk.isEmpty()) {
                    chunks.add(currentChunk.toString().trim());
                    currentChunk.setLength(0);
                }
                currentChunk.append(sentence).append(". ");
            }
        }
        
        // 3. 处理最后一个块
        if (!currentChunk.isEmpty()) {
            chunks.add(currentChunk.toString().trim());
        }
        return chunks;
    }

public static void main(String[] args) {
    String text = "The quick brown fox jumps over the lazy dog. It is a bright sunny day. Large language models are transforming AI.";
    List<String> chunks = semanticChunk(text, 50);
    
    // 输出分块结果
    System.out.println("语义分块结果:");
    chunks.forEach(chunk -> 
        System.out.println("[" + chunk.length() + "字符] " + chunk)
    );
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.

c. 递归字符分块

初始块基于字符限制创建。如果它们太大,则递归地分成更小的、具有语义意义的单元(例如,句子)。

如何工作:

  • 最初,根据字符限制(例如,500 个字符)创建大块内容。
  • 如果一个块超过了限制,它将被递归地分割成更小的有意义的单位,例如句子。

优势:

  • 保留语义完整性,同时遵守尺寸限制。
  • 非常适合存在 API 限制的情况(例如,OpenAI 的令牌限制)。

缺点:

  • 递归处理会增加计算时间。

示例:

  • 文本分块将文本分解成更小的部分。此方法增强了检索。
  • 递归字符限制(20 个字符):
  • 文本块 1:“分块处理文本”
  • 块 2:“分成更小的部分。”
  • 块 3:“此方法增强”
  • 块 4:“检索。”

优先级分隔符列表递归分块器会按以下顺序尝试分割符(优先级从高到低):

这些分隔符对应自然语义边界,如段落、句子、标点等。

java

["\n\n", "\n", "。", "!", "?", " ", ""]  // 中文场景可能调整为以句号、段落为主
  • 1.
  • 2.
  • 3.
public static List<String> recursiveChunk(String text, int charLimit) {
        List<String> result = new ArrayList<>();
        if (text.length() <= charLimit) {
            result.add(text.trim());
            return result;
        }

        int midpoint = text.length() / 2;
        // 向后查找句子边界[1](@ref)
        for (int i = midpoint; i < text.length(); i++) {
            if (".!?".indexOf(text.charAt(i)) != -1) {
                // 递归处理剩余文本[3](@ref)
                result.add(text.substring(0, i + 1).trim());
                result.addAll(recursiveChunk(text.substring(i + 1).trim(), charLimit));
                return result;
            }
        }
        // 未找到边界时强制分割
        return splitFallback(text, charLimit);
    }

    private static List<String> splitFallback(String text, int charLimit) {
        List<String> chunks = new ArrayList<>();
        int splitPoint = Math.min(charLimit, text.length());
        chunks.add(text.substring(0, splitPoint).trim());
        if (splitPoint < text.length()) {
            chunks.addAll(recursiveChunk(text.substring(splitPoint).trim(), charLimit));
        }
        return chunks;
    }

    // 示例测试
    public static void main(String[] args) {
        String text = "This is a long text. It needs to be split into chunks! But where should we split? Let's test the recursive approach.";
        System.out.println(recursiveChunk(text, 30));
        /* 输出:
        [This is a long text., It needs to be split into chunks!, But where should we split?, Let's test the recursive approach.] */
    }
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.

d. 自适应分块

动态调整块大小,根据内容的复杂度或重要性,利用自然语言处理技术识别逻辑终点。

如何工作:

  • 动态调整块大小,基于内容复杂度。
  • 使用先进的自然语言处理技术来查找逻辑端点。

优势:

  • 平衡计算效率和语义连贯性。
  • 有效处理复杂和可变长度的内容。

缺点:

  • 实施复杂性。

示例:

  • “简单想法适合小块。复杂概念需要更大的块。”
  • 自适应块
  • 块 1:“简单想法适合小块。”
  • 块 2:“复杂概念需要更大的块。”
// 核心分块方法
    public static List<String> adaptiveChunk(String text, String modelPath) throws IOException {
        try (FileInputStream modelStream = new FileInputStream(modelPath)) {
            SentenceModel model = new SentenceModel(modelStream);
            SentenceDetectorME detector = new SentenceDetectorME(model);
            
            List<String> chunks = new ArrayList<>();
            for (String sent : detector.sentDetect(text)) {
                chunks.add(sent.trim()); // 自动去除首尾空格
            }
            return chunks;
        }
    }

    // 使用示例
    public static void main(String[] args) {
        try {
            String text = "Large language models have revolutionized NLP. They enable adaptive text chunking! Do you agree?";
            List<String> chunks = adaptiveChunk(text, "en-sent.bin");
            
            chunks.forEach(System.out::println);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.

e. 混合分块

  • 解释:结合固定长度和语义分块,允许在块大小上保持灵活性,同时保持语义连贯性。

如何工作:

  • 结合固定大小和语义分块策略。
  • 允许在保持上下文的同时调整块大小。

优势:

  • 可定制以适应特定应用。
  • 平衡精度与效率。

缺点:

  • 需要仔细调整以避免冗余或失真。

示例:

  • “分块提高检索效率。灵活性对于精确度至关重要。”
  • 混合块:
  • 块 1:“分块提高检索。”
  • 块 2:“灵活性对精度至关重要。”
public class HybridRetriever {
    private final IndexSearcher bm25Searcher;
    private final VectorSimilarityFunction vectorSim;

    public HybridRetriever(String indexDir) throws IOException {
        DirectoryReader reader = DirectoryReader.open(FSDirectory.open(Paths.get(indexDir)));
        this.bm25Searcher = new IndexSearcher(reader);
        this.vectorSim = new VectorSimilarityFunction(); // 自定义向量相似度计算
    }

    public List<Document> hybridSearch(String query, int topK) throws IOException {
        // BM25检索
        Query bm25Query = new QueryParser("content", new StandardAnalyzer()).parse(query);
        TopDocs bm25Results = bm25Searcher.search(bm25Query, topK*2);

        // 向量检索
        float[] queryVector = vectorSim.encode(query);
        TopDocs vectorResults = vectorSim.search(queryVector, topK*2);

        // 结果融合(简单示例)
        return mergeResults(bm25Results.scoreDocs, vectorResults.scoreDocs, topK);
    }

    private List<Document> mergeResults(ScoreDoc[] bm25, ScoreDoc[] vector, int topK) {
        // 实际应使用RRF等高级融合算法
        List<Document> merged = new ArrayList<>();
        for (ScoreDoc doc : bm25) {
            merged.add(bm25Searcher.doc(doc.doc));
        }
        for (ScoreDoc doc : vector) {
            if (!merged.contains(bm25Searcher.doc(doc.doc))) {
                merged.add(bm25Searcher.doc(doc.doc));
            }
        }
        return merged.subList(0, Math.min(topK, merged.size()));
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.

f. 重叠分块

块重叠一定间距,确保边界处不丢失上下文。

如何工作:

  • 创建重叠块以保留边界之间的上下文。
  • 确保一个块中的关键思想不会在块之间丢失。

优势:

  • 保留更多上下文。
  • 提升检索准确性。

缺点:

  • 冗余增加内存使用。

影响:跨边界保持上下文,提高检索质量。

示例:

  • 文本分块通过将文本分解成部分来提高检索。
  • 重叠块(5 词重叠):
  • 块 1:“分块通过分割提高检索”
  • 块 2:“通过将文本拆分来检索”
  • 块 3:“将文本拆分成部分。”

g. 字符文本分割

如何工作:

  • 根据特定的字符限制分割文本。

优势:

  • 快速且直接。
  • 适用于具有令牌或字符限制的系统。

缺点:

  • 风险在于字符在单词或句子中间掉落时破坏意义。

h. Markdown文档特定拆分

Markdown 分割

Markdown 特定的分割确保基于结构标记(如标题或列表)的逻辑分组。

public static List<String> split(String markdown) {
        List<String> chunks = new ArrayList<>();
        Parser parser = Parser.builder().build();
        Node document = parser.parse(markdown);
        
        // 第一级分块:按主要结构元素分割
        SplitVisitor visitor = new SplitVisitor();
        document.accept(visitor);
        List<String> sections = visitor.getSections();
        
        // 第二级分块:合并小段并递归分割大块
        mergeAndSplit(sections, chunks);
        return chunks;
    }

    private static void mergeAndSplit(List<String> sections, List<String> chunks) {
        StringBuilder buffer = new StringBuilder();
        for (String section : sections) {
            if (buffer.length() + section.length() <= CHUNK_SIZE) {
                buffer.append(section);
            } else {
                if (buffer.length() > 0) {
                    chunks.add(buffer.toString());
                    buffer.setLength(0);
                }
                if (section.length() > CHUNK_SIZE) {
                    recursiveSplit(section, chunks);
                } else {
                    buffer.append(section);
                }
            }
        }
        if (buffer.length() > 0) {
            chunks.add(buffer.toString());
        }
    }

    private static void recursiveSplit(String text, List<String> chunks) {
        if (text.length() <= CHUNK_SIZE) {
            chunks.add(text);
            return;
        }
        
        // 寻找最佳分割点(优先选择段落边界)
        int splitPos = findSplitPosition(text);
        chunks.add(text.substring(0, splitPos).trim());
        recursiveSplit(text.substring(splitPos), chunks);
    }

    private static int findSplitPosition(String text) {
        // 查找最近的段落分隔符
        int splitPos = Math.min(CHUNK_SIZE, text.length());
        for (int i = splitPos; i >= 0; i--) {
            if (i < text.length() && text.charAt(i) == '\n') {
                return i + 1;
            }
        }
        return splitPos;
    }

    static class SplitVisitor extends AbstractVisitor {
        private final List<String> sections = new ArrayList<>();
        private final StringBuilder currentSection = new StringBuilder();

        @Override
        public void visit(Heading heading) {
            flushSection();
            super.visit(heading);
        }

        @Override
        public void visit(FencedCodeBlock codeBlock) {
            flushSection();
            sections.add(codeBlock.getLiteral());
        }

        @Override
        public void visit(Paragraph paragraph) {
            paragraph.accept(new TextCollector(currentSection));
            currentSection.append("\n\n");
        }

        List<String> getSections() {
            flushSection();
            return sections;
        }

        private void flushSection() {
            if (currentSection.length() > 0) {
                sections.add(currentSection.toString().trim());
                currentSection.setLength(0);
            }
        }
    }

    static class TextCollector extends AbstractVisitor {
        private final StringBuilder builder;

        TextCollector(StringBuilder builder) {
            this.builder = builder;
        }

        @Override
        public void visit(Text text) {
            builder.append(text.getLiteral());
        }
    }

    // 使用示例
    public static void main(String[] args) {
        String markdown = "# Header\n\nThis is a sample paragraph.\n\n```\ncode block\n```\n\nAnother paragraph.";
        List<String> chunks = split(markdown);
        chunks.forEach(chunk -> System.out.println("Chunk:\n" + chunk + "\n"));
    }
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.
  • 62.
  • 63.
  • 64.
  • 65.
  • 66.
  • 67.
  • 68.
  • 69.
  • 70.
  • 71.
  • 72.
  • 73.
  • 74.
  • 75.
  • 76.
  • 77.
  • 78.
  • 79.
  • 80.
  • 81.
  • 82.
  • 83.
  • 84.
  • 85.
  • 86.
  • 87.
  • 88.
  • 89.
  • 90.
  • 91.
  • 92.
  • 93.
  • 94.
  • 95.
  • 96.
  • 97.
  • 98.
  • 99.
  • 100.
  • 101.
  • 102.
  • 103.
  • 104.
  • 105.
  • 106.
  • 107.
  • 108.
  • 109.
  • 110.
  • 111.
  • 112.
  • 113.
  • 114.

i. 代理分块

代理分块引入了基于命题的分块方法,其中句子或命题通过基于LLM的处理流程提取。这些命题使用AgenticChunker逻辑分组。

j. 动态分块(滑动窗口策略)

public class DynamicChunker {
    private static final int WINDOW_SIZE = 128;
    private static final int OVERLAP = 25;

    public List<TextChunk> chunkWithOverlap(String text) {
        List<String> tokens = SegmentUtil.segment(text); // 使用分词工具
        List<TextChunk> chunks = new ArrayList<>();

        int start = 0;
        while (start < tokens.size()) {
            int end = Math.min(start + WINDOW_SIZE, tokens.size());
            List<String> chunkTokens = tokens.subList(start, end);
            chunks.add(new TextChunk(String.join(" ", chunkTokens)));

            start += (WINDOW_SIZE - OVERLAP); // 滑动窗口步长
        }
        return chunks;
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.

k. 关键点

  1. 精度:先进的分割策略,如语义和代理分块,确保高质量的分段。
  2. 与 RAG 集成:直接集成到基于检索的工作流程中,以增强性能。

18. 召回率优化

优化 RAG(Retrieval-Augmented Generation)的召回率需要从检索模块的多个环节入手,以下是一套系统性优化方案,结合技术原理和工程实践:

一、数据预处理优化:提升知识库质量
分块策略增强

动态分块:结合语义边界(如段落、列表)和固定长度,使用混合分块策略

public class HybridChunker {
    public static List<String> hybridChunk(String text, int maxSize) {
        List<String> chunks = new ArrayList<>();
        
        // 第一阶段:按标题分块(示例按Markdown的#分割)
        String[] sections = text.split("\n#+ ");
        for (String section : sections) {
            if (section.length() <= maxSize) {
                chunks.add(section.trim());
            } else {
                // 第二阶段:递归分割超长块
                chunks.addAll(recursiveSplit(section, maxSize));
            }
        }
        return chunks;
    }

    private static List<String> recursiveSplit(String text, int maxSize) {
        List<String> result = new ArrayList<>();
        if (text.length() <= maxSize) {
            result.add(text.trim());
            return result;
        }
        
        // 查找最近的段落分隔符
        int splitPos = findSplitPoint(text, maxSize);
        result.add(text.substring(0, splitPos).trim());
        result.addAll(recursiveSplit(text.substring(splitPos), maxSize));
        return result;
    }

    private static int findSplitPoint(String text, int maxSize) {
        int pos = Math.min(maxSize, text.length());
        for (int i = pos; i >= 0; i--) {
            if (text.charAt(i) == '\n') return i + 1;
        }
        return pos;
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.

a. 示例:混合分块(优先按标题分割,超长段落递归分割)

public class HybridRetriever {
    private final IndexSearcher bm25Searcher;
    private final VectorSimilarityFunction vectorSim;

    public HybridRetriever(String indexDir) throws IOException {
        DirectoryReader reader = DirectoryReader.open(FSDirectory.open(Paths.get(indexDir)));
        this.bm25Searcher = new IndexSearcher(reader);
        this.vectorSim = new VectorSimilarityFunction(); // 自定义向量相似度计算
    }

    public List<Document> hybridSearch(String query, int topK) throws IOException {
        // BM25检索
        Query bm25Query = new QueryParser("content", new StandardAnalyzer()).parse(query);
        TopDocs bm25Results = bm25Searcher.search(bm25Query, topK*2);

        // 向量检索
        float[] queryVector = vectorSim.encode(query);
        TopDocs vectorResults = vectorSim.search(queryVector, topK*2);

        // 结果融合(简单示例)
        return mergeResults(bm25Results.scoreDocs, vectorResults.scoreDocs, topK);
    }

    private List<Document> mergeResults(ScoreDoc[] bm25, ScoreDoc[] vector, int topK) {
        // 实际应使用RRF等高级融合算法
        List<Document> merged = new ArrayList<>();
        for (ScoreDoc doc : bm25) {
            merged.add(bm25Searcher.doc(doc.doc));
        }
        for (ScoreDoc doc : vector) {
            if (!merged.contains(bm25Searcher.doc(doc.doc))) {
                merged.add(bm25Searcher.doc(doc.doc));
            }
        }
        return merged.subList(0, Math.min(topK, merged.size()));
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.

b. 重叠分块

设置10%-20%的重叠比例,保留上下文连贯性
python
chunk = text[i:i+chunk_size]
next_start = i + chunk_size - overlap_size # 滑动窗口

c. 数据清洗

去除非文本元素(如HTML标签、特殊符号)
标准化文本格式(统一大小写、标点)
添加元数据(文档来源、章节标题、关键词)
知识增强

外部知识注入:补充行业术语表、同义词词典
数据平衡:对低频关键概念进行过采样

19. 检索模型优化:提升语义匹配能力

a. 嵌入模型选择

使用领域适配的嵌入模型(如微调后的text2vec-large-chinese)

ⅰ. 综合选型建议

维度

text2vec-large-chinese

M3E-base

Jina-v2-base-zh

Baichuan

中文任务精度

中上

多语言支持

有限

中英双语

中英双语(长文本优化)

仅中文

上下文长度

512 token

512 token

8K token

512 token

部署成本

低(开源)

低(开源)

高(需 API 或算力)

适用场景

垂直领域、轻量检索

通用检索、多任务

长文本、跨语言

企业级复杂任务

总结

  • 若需 长文本处理跨语言能力,优先选择 Jina-v2
  • 追求 中文任务综合性能M3E-baseBaichuan 更优;
  • text2vec-large-chinese垂直领域微调本地化部署 上更具灵活性

b. 检索重排序

检索重排(Re-ranking) 是一个关键优化步骤,其核心作用是通过对初步检索结果进行精细化排序,提升最终生成答案的质量和相关性

ⅰ. 为什么需要检索重排?
  • 初步检索的局限性
  • 第一阶段的检索(如BM25、向量检索)通常以速度优先,可能返回大量相关但冗余或噪声的文档。
  • 粗粒度匹配可能忽略语义细微差异(例如“苹果公司” vs. “水果苹果”)。
  • 生成模型的依赖
  • RAG的生成模块(如GPT)严重依赖检索结果的质量。若检索结果不精准,生成答案可能包含错误或无关内容
ⅱ. 检索重排的核心作用
1. 提升相关性精度
  • 语义深度理解:使用更复杂的模型(如交叉编码器Cross-Encoder)对检索结果重新打分,综合上下文判断相关性。
  • 例:对问题“如何避免手机电池老化?”,初步检索可能返回“电池保养方法”和“电池化学原理”,重排模型能识别前者更符合用户需求。
  • 指标优化:在评测中,重排可使NDCG(归一化折损累积增益)等指标提升10-30%。
2. 过滤噪声与冗余
  • 去重与聚焦:合并内容重复的文档,优先保留信息密度高、与查询意图强相关的段落。
  • 例:客服场景中,检索到10条相似回答,重排可筛选出最简洁、权威的一条供生成模型参考。
3. 适配生成模型需求
  • 上下文长度优化:生成模型(如LLM)有输入长度限制,重排可截取关键片段,避免因超长文本丢失重点。
  • 例:从8K token的长文档中提取3段最相关文本,确保生成时聚焦核心内容。
ⅲ. 技术实现方式
1. 经典方法
  • 交叉编码器(Cross-Encoder)
  • 将查询和文档拼接输入BERT类模型,直接输出相关性得分,精度高但计算代价大(适合Top 100内重排)。
  • 工具:Sentence-BERT的CrossEncoder('model_name')
  • ColBERT
  • 基于交互式迟交互(Late Interaction),平衡精度与速度,支持大规模检索场景。
2. 轻量化策略
  • 两阶段Pipeline
  1. 粗排:用双塔模型(Bi-Encoder,如text2vec)快速召回Top 100。
  2. 精排:用Cross-Encoder对Top 10进行精细排序。
  • 蒸馏模型:将大型重排模型(如BERT-large)蒸馏为轻量版,兼顾精度与速度。

20. 核心框架对比

a. 检索能力对比

框架

检索类型

向量数据库支持

混合搜索实现难度

LangChain

原生支持混合搜索

Pinecone/FAISS等30+种

开箱即用(HybridRetriever)

LangChain4j

Java 生态的模块化 AI 框架,对标 Python LangChain,强调灵活性和企业级扩展性

Milvus/PgVector/Redis等20+种

支持动态工具链、三级 RAG 架构(Easy/Naive/Advanced),与 Spring Boot 深度集成可手动合并BM25+向量结果

Spring AI

Spring 官方推出的企业级 AI 框架,提供标准化抽象和全链路 RAG 管道,强调开箱即用和工程化能力

支持Elasticsearch/PgVector等

内置混合检索(BM25+向量)、负载感知分块,原生集成 Spring Security 和 Micrometer 监控

a. RAG 能力专项对比
  • LangChain
  • 需手动组装向量库、嵌入模型、分块策略等组件,灵活但需较高开发成本
  • 典型流程:文档加载→分块→嵌入→向量存储→检索→重排序→生成。
  • LangChain4j
  • 三级 RAG 架构
  • Easy RAG:默认向量库(如 Pinecone)+ 嵌入模型(如 OpenAI),开箱即用。
  • Advanced RAG:支持多源检索(跨数据库联合查询)、查询转换(Query Rewriting)、重排序模块。
  • 动态分块策略(递归分块 + 语义边界检测),重叠窗口可动态调整
  • Spring AI
  • 内置 ETL 管道:支持 Chroma、PGVector 等向量库,数据预处理自动化(如 PDF 解析、元数据提取)
  • 混合检索:结合关键词(BM25)与向量搜索,召回率提升 18%(对比纯向量)
2. 性能优化
  • LangChain4j
  • 支持流式响应和异步处理,适用于高并发场景(如实时客服系统)
  • 智能缓存策略:对话历史缓存 + 向量索引增量更新
  • Spring AI
  • 负载感知分块:根据文本复杂度动态调整块大小(300-1000 字符)
  • 企业级监控:通过 Micrometer 输出 Token 消耗、模型调用耗时等指标。

b. 综合能力对比

能力维度

LangChain(python)

LangChain4j(java)

Spring AI(java)

核心设计

动态链式工作流、多模态支持、实验性扩展

链式结构、内存优化、标准化API

Spring生态集成、模块化设计

RAG流程

原生支持混合搜索(向量+关键词)、动态语义分块

支持混合检索、固定分块策略、语义分块,正则分块等

模块化RAG流水线、ETL框架支持

企业集成特性

社区插件丰富,但缺乏官方安全控制

无缝集成Spring,支持高并发,支持分布式对话状态存储(Redis)和权限管理

深度整合Spring生态(如Spring Data、Security)

模型支持范围

支持 15+ LLM 提供商(含 OpenAI、ChatGLM、Qianfan 等),20+ 向量存储

支持 15+ LLM 提供商(含 OpenAI、ChatGLM、Qianfan 等),20+ 向量存储

专注主流模型(OpenAI、DeepSeek、Hugging Face),深度集成云厂商(Azure、AWS)

多工具协作

支持复杂 Agent 网络(如 AutoGen 多 Agent)

内置顺序链、并行链、循环链,支持业务流程编排

典型场景

研究型项目、多模型实验,智能体应用

企业内部知识库问答、复杂对话系统,智能体应用

高并发支持

一般(Python GIL限制)

优(Java线程模型)

优(Spring Reactive)

响应延迟

200-500ms

100-300ms

150-400ms

开发成本

低(快速原型)

中(需Java定制)

高(模块化配置)

运维成本

高(Python依赖管理)

低(JAR包部署)

中(Spring)

21. 向量数据库选型对比

LLM-Rag原理解析_递归_14

ⅰ. 关键差异点
  1. 与 Pinecone 对比
  • 优势:Milvus 开源且支持自托管,成本更低;支持更复杂的搜索场景(如多向量混合召回)。
  • 劣势:Pinecone 作为全托管服务,运维成本更低,适合无技术团队的企业。
  1. 与 Qdrant/Weaviate 对比
  • 性能:Milvus 在超大规模数据集(10亿级)下性能显著领先,Qdrant 资源占用更少,Weaviate 擅长语义理解。
  • 功能:Qdrant 支持地理信息搜索,Weaviate 内置生成式搜索,Milvus 则提供更灵活的索引选择和 GPU 加速。
  1. 与 ElasticCloud 对比
  • 混合搜索:ElasticCloud 支持全文检索与向量混合查询,但向量性能弱于 Milvus;Milvus 适合纯向量场景,需与外部系统(如 ES)整合实现混合搜索。
  1. 与 Faiss 对比
  • 定位差异:Faiss 是底层向量索引库,需自行构建数据库服务层;Milvus 提供完整的数据库功能(如持久化、事务管理等)。

ⅱ. 选型建议
  1. 选择 Milvus 的场景
  • 需要处理 超大规模向量数据(如十亿级),且对 多模态搜索GPU 加速 有强需求。
  • 企业希望兼顾 开源灵活性分布式扩展能力,技术团队具备一定运维能力。
  1. 其他场景替代方案
  • 快速全托管服务 → Pinecone
  • 轻量级开源+自托管 → Qdrant
  • 语义搜索+AI 集成 → Weaviate
  • 已有 ES 生态扩展 → ElasticCloud
  • 底层算法优化 → Faiss
ⅲ. Milvus 核心优势
  1. 性能表现
  • 在多项基准测试中,Milvus 在 QPS(每秒查询数)召回率延迟控制 上表现最优,尤其在处理大规模数据(10亿级向量)时性能显著优于 Qdrant 和 Chroma。
  • 支持多种索引类型(如 HNSW、IVF、ANNOY 等),可根据场景选择最优索引策略,实现精度与速度的平衡。
  1. 开源与扩展性
  • 完全开源(Apache 2.0 协议),支持自托管和云托管(Milvus Cloud),适合企业级分布式部署。
  • 水平扩展能力强,支持动态分片和负载均衡,适合超大规模数据处理610。
  1. 功能丰富性
  • 支持 混合查询(向量+标量过滤)、多向量搜索(同一实体的多种向量表示)和 GPU 加速,适用于多模态场景(如图像+文本联合搜索)。
  • 提供可视化界面(Attu)和丰富的 SDK(Python、Java、Go 等),降低运维复杂度。
  1. 生态集成
  • 与主流 AI 工具链(如 PyTorch、TensorFlow)和数据处理框架(Apache Spark)无缝集成,适配生成式 AI 和 RAG 应用

ⅳ. 总结

Milvus 在 性能扩展性功能丰富度 上综合表现最优,但需权衡其运维复杂度。若业务场景需处理超大规模数据或需深度定制搜索策略,Milvus 是首选;若追求开箱即用或轻量部署,可考虑 Pinecone 或 Qdrant

22. 参考信息

 https://docs.langchain4j.dev/

 https://docs.spring.io/spring-ai/reference/1.0/index.html