知识库的构建 [ 3 ]

知识库构建

文本分块

递归分块

我们前面已经完成了语义分块 的功能开发,接下来我们学习另外一种分块方式:递归分块(Recursive Chunking)

首先我们先搞懂什么是递归分块:

递归分块(Recursive Chunking),先按主题或段落初步划分,再对超长块递归细分,直至满足大小限制。递归分块融合了结构化与非结构化处理逻辑,与固定大小的分块不同,这种方法保持了语言的自然流畅性并保留了完整的内容语义。

递归分块的核心逻辑是: 先按照主题、段落 对文本做初步大块划分,划分完成之后,再判断当前分块的文本长度。如果存在超长文本块,就对超长块再次细分,反复迭代,直到所有分块都满足预设的大小限制。

它的逻辑和我们写代码中的递归调用非常相似:执行一次分块,得到一批文本块;如果其中还有超出大小限制的块,就再次进入分块逻辑,继续切割,直到所有分块大小合规、不再超限为止。

递归分块融合了结构化与非结构化的文本处理逻辑。它和固定大小分块有本质区别:

固定大小分块是机械按字符硬切 ,很容易切断句子、破坏语义连贯性;而递归分块优先按照段落、主题边界切割,最大程度保留语言流畅性和完整语义,整体语义连贯性远优于固定大小分块。

理解完递归分块的定义与优势,接下来我们正式编写递归分块的完整代码。

我们在 PyCharm 中新建 Python 文件,命名为 recursiveChunk.py,专门用于实现递归分块逻辑。

文件创建完成后,首先导入所需依赖包:

第一,从 typing 导入 List 用于类型注解;第二,从 llama_index.core.schema 导入 Document 文档对象,保证最终返回值为标准文档对象集合;同时导入我们项目的 util 工具模块与 config 配置模块。

接下来定义核心方法 recursive_chunk_documents,传入 input_str 目录字符串参数,返回值类型为 List[Document]

方法第一步,获取清洗后的标准文档数据。 调用 util.clean_all_formats(input_str),传入目录路径,批量读取、清洗目录下的所有文件,得到干净的 documents 文档集合。

结合递归分块的原理我们可以知道:递归分块优先依靠文本天然分隔符做段落、主题划分。我们需要依靠文本中的特殊符号作为分块依据,包括:换行段落符、中英文句号、感叹号、问号、分号、逗号、空格等一系列语义边界符号。

因此我们提前定义好分隔符优先级列表,优先级从高到低排列:优先按照完整段落切割,段落切割不了再依次降级使用换行、句末标点、分隔符、空格等符号,最大程度保证分块语义完整。

第二步,创建递归分块专属分块器。 实例化 TokenTextSplitter,这是 LlamaIndex 框架提供的递归文本分块器

我们重点配置两个核心参数:

  • 第一个,separator:首选分隔符,我们指定优先级最高的段落分隔符,优先按照完整段落拆分文本;

  • **第二个,backup_separators:备用分隔符列表。**如果文本没有段落标识、段落过长,就自动降级使用后续的换行、标点、符号等备用分隔符继续细分,完美适配超长文本的递归切割逻辑。

分块器初始化完成后,开始批量处理文档。首先定义空数组 chunks,用于承接所有递归分块后的最终结果。

循环遍历每一个清洗后的文档对象:

首先取出文档文本 text = d.text,做非空判断,如果文本为空,直接 continue 跳过当前循环,避免无效处理。

同时通过元数据获取当前文档的原始文件路径 file_path,用于后续溯源。

调用分块器的 get_nodes_from_documents 方法,对单篇文档执行递归分块,传入封装好的 Document 对象,携带当前文本与原始路径元数据,执行后得到分块后的 nodes 节点数组。

这里和语义分块逻辑一致:一篇原始文档,经过递归细分后,可能会生成多个独立的节点分块。

我们继续遍历所有节点,对节点元数据进行自定义更新: 拷贝原始元数据,新增 source_file_path 原始文件路径、chunk_index 当前分块序号,精准记录每一个递归分块的来源与顺序。

元数据更新完成后,实例化全新的 Document 对象,传入节点文本与更新后的元数据,将最终生成的递归分块追加到 chunks 结果数组中。

所有文档分块完成后,我们添加遍历打印逻辑:循环打印每一个分块的序号、元数据、分块文本,直观查看递归分块的切割效果。

最后方法返回完整的 chunks 分块集合。

到这里,我们基于多级分隔符、自动降级细分的递归分块完整逻辑就全部编写完成。

recursiveChunk.py

cpp 复制代码
from typing import List
from llama_index.core.schema import Document
from llama_index.core.node_parser import TokenTextSplitter
import util
import config


def recursive_chunk_documents(
    input_str: str
) -> List[Document]:
    """
    递归式文本分块函数
    对输入原始文本做清洗,使用多优先级分隔符递归拆分文本,生成带元数据的Document分块列表
    适配RAG知识库构建,优先在语义边界(段落、句子)切割,避免把完整语义拆碎

    Args:
        input_str: 原始输入文本路径字符串,会交给util做格式清洗处理

    Returns:
        List[Document]: 分块完成后的Document对象列表,每个对象包含分块文本+元数据信息
    """
    # 1. 调用工具函数,对原始输入做格式清洗,返回清洗后的Document列表
    # 清洗逻辑由util.clean_all_formats实现,会处理格式垃圾、多余符号等
    documents = util.clean_all_formats(input_str)
    print(f"清洗后的数据长度:{len(documents)}")

    # 2. 定义分隔符优先级列表
    # 分割逻辑:优先使用靠前的分隔符做切割;当前分隔符无法满足块大小时,自动向后使用备用分隔符
    # 优先级从高到低:段落分隔 -> 换行符 -> 中文句末标点 -> 英文句末标点 -> 分号 -> 逗号 -> 空格 -> 单字符兜底
    separators = [
        "\n\n",    # 段落分隔,最高优先级,尽量按段落切分
        "\n",      # 普通换行
        "。", "!", "?",  # 中文句号、感叹号、问号,句子边界
        ".", "!", "?",     # 英文句号、感叹号、问号
        ";", ";",          # 分号
        ",", ",",          # 逗号
        " ",               # 空格
        "",                # 兜底,单字符分割,实在无法分割时使用
    ]

    # 3. 初始化TokenTextSplitter分块器
    # separator:首选分割符,优先使用段落分隔符\n\n
    # backup_separators:备用分隔符列表,首选分割符不满足块大小条件时依次尝试
    spliter = TokenTextSplitter(
        separator=separators[0],
        backup_separators=separators[1:],
    )

    # 存储最终分块结果的列表
    chunks = []

    # 遍历每一份清洗后的原始文档,逐个执行分块
    for d in documents:
        text = d.text
        # 空文本直接跳过,不生成无效分块
        if not text:
            continue

        # 从原始文档元数据中取出文件路径
        path = d.metadata.get("file_path")

        # 传入文档对象,调用分块器生成节点Nodes
        # 注意:这里重新构造Document,传入文本和原始文件路径元数据
        nodes = spliter.get_nodes_from_documents(
            [Document(text=text, metadata={"file_path": path})]
        )

        # 遍历分块生成的节点,扩充元数据,转为Document对象存入结果
        for idx, n in enumerate(nodes):
            # 复制原有节点元数据,防止修改原始对象
            m = dict(n.metadata)
            # 更新追加自定义元数据
            m.update(
                {
                    "source_file_path": path,   # 来源文件路径,用于溯源
                    "chunk_index": idx,         # 当前文档内的分块序号
                }
            )
            # 构建新Document对象,加入结果列表
            chunks.append(Document(text=n.text, metadata=m))

    # 打印输出所有分块信息,用于调试查看分块效果
    for i, chunk in enumerate(chunks):
        print(f"第{i+1}个分块")
        print(chunk.metadata)
        print(chunk.text)

    # 返回全部分块后的Document列表,可用于后续向量化、存入向量库
    return chunks


if __name__ == "__main__":
    """
    程序入口,测试分块函数
    读取配置里的文档路径,执行递归分块
    """
    recursive_chunk_documents(config.document_path)

接下来我们测试递归分块的实际运行效果。

我们直接编写程序入口,调用我们写好的 recursive_chunk_documents 方法,目录直接使用 config.document_path,直接加载我们文档文件夹里面的4个文件进行整体测试。

我们执行代码,观察分块结果。

运行完成后可以看到,本次递归分块最终分出了26个分块 。大家回忆一下,我们的文件夹里面的文件经过解析、数据清洗之后,得到的恰好就是 26 个 Document 对象

这就说明第一轮测试是一对一关系 :清洗后的一个 Document 进入递归分块器,最终只生成一个分块,没有产生任何额外的新分块。

我们来分析为什么会出现一对一的效果。

原因非常简单,大家看我们使用的 TokenTextSplitter。递归分块本质上属于特殊的固定大小分块 ,它内部自带默认的 chunk_size 大小限制,默认值是 1024

而我们当前这批经过清洗得到的文档,每一段文本长度本身就小于1024,完全没有超限。既然没有超长文本,递归分块器就不需要二次细分,所以最终结果就是一对一,不会多分。

那我们怎么才能体现出递归分块自动细分、递归切割的特性?我们需要制造超长文本,让它触发递归拆分逻辑。

操作方式很简单:我们随便挑选一个测试 txt 文件,把里面的内容多次复制叠加。我这里把原内容复制三遍、叠加成四倍内容,人为把单个文件的文本长度拉大、做长。

文件内容加长完成后,我们再次运行代码,观察分块数量变化。

再次执行完毕,大家可以看到,现在总分块数变成了 29 个分块

这就验证了递归分块的核心特性:我们仅仅把其中一个 txt 文件内容加长,没有新增文件、没有新增文档,但是递归分块的块数变多了

第一次测试一对一,是因为文本太短、未超限;现在文本超长,触发了 TokenTextSplitter 的递归细分逻辑,一个文档被切分成了多块,总分块数量自然上涨。

这就引出本重要的知识点,大家一定要区分开三层完全不同的概念:

第一层是物理文件 :磁盘上真实存在的 .txt 等文件;

第二层是清洗后的 Document 文档 :物理文件经过解析、清洗、去格式之后,生成的标准文档对象,只和文件数量、解析规则有关,不会因为文本变长而增多

第三层是最终分块 :一个清洗后的 Document,文本短就一块,文本超长就会被递归切分成多块,分块数量是动态变化的

总结一句话:物理文件不变、清洗后的文档数量不变,只有递归分块的数量会随着文本长度自动变化,这就是递归分块的核心工作机制。

基于文档结构的分块

接下来我们来实现基于文档结构的分块。我们先来理解什么是基于文档结构的分块。

基于文档结构的分块是指利用文档本身的标记语言语法或特定的文件格式属性,将文档划分为逻辑独立的单元。它不再简单地追求 "块的大小",而是追求 "块的完整性"。每一块通常对应文档中的一个章节、一个子项或一个完整的表格。

基于文档结构的分块,就是利用文档本身标记语言的语法特性,以及文件自身格式属性,把文档划分成逻辑上相互独立的单元。所以这种分块方式,是严格依赖文件本身格式的。

举几个例子,像PDF、PPT、CSV这类不同格式的文件,我们就依靠文件自带格式的特性来做分块。

这种分块方式,不再单纯追求分块的大小,核心追求的是块的完整性。要保证每一个分块,对应原始文档里的一个章节、一个子项,或者一整个完整的表格。

当然我们没办法把所有文件类型全部都实现一遍,我们就使用之前数据集里的四种文件类型作为示例:PPT、PDF、CSV、TXT。大家后续如果遇到其他文件格式,可以参照这里的代码思路自己扩展。

理解完原理,我们开始编写代码。新建一个Python文件,命名为structureChunk.py,用来实现基于文档结构的分块逻辑。

首先导入需要的依赖: List类型注解、Document文档对象,还有我们之前写好的util工具模块以及config配置模块。

虽然我们是按文档结构分块,但我们依旧需要设置分块的大小上限,这里我们设定max_chunk_chars = 1500,给分块设置一个字符数量的上限。

接下来定义主函数structure_chunk_documents ,入参是目录路径字符串input_str,返回值类型为List[Document]

函数第一步,依旧是获取清洗完成后的文档数据 ,调用util.clean_all_formats(input_str),传入目录路径,得到清洗后的documents集合。

拿到清洗完成的文档之后,我们需要对每一篇文档逐篇进行分块。

我们先创建空列表chunks,用来存放最终所有分块结果。然后遍历每一个清洗后的文档对象。

这里要注意: 一个清洗后的Document,经过文档结构分块之后,有可能会生成多个分块。所以我们封装一个辅助方法_chunk_single_document,把单篇文档传入这个方法,完成单文档的分块处理。

处理完成之后,把返回的分块结果,使用extend追加到chunks结果列表中。

全部文档处理完毕,我们加上测试打印逻辑,和前面几种分块保持一致:循环遍历chunks,打印每一个分块的序号、元数据、分块文本,方便我们查看运行效果。

最后返回完整的chunks列表。

python 复制代码
from typing import List
from llama_index.core.schema import Document
import config
import util

max_chunk_chars = 1500

def _chunk_single_document(
        d: Document
)->List[Document]:
    pass

def structure_chunk_documents(
        input_dir: str
) -> List[Document]:
    documents = util.clean_all_formats(input_dir)
    
    chunks: List[Document] = []
    for d in documents:
        doc_chunks = _chunk_single_document(d)
        chunks.extend(doc_chunks)
    
    for i, chunk in enumerate(chunks):
        print(f"第 {i + 1} 个分块")
        print(chunk.metadata)
        print(chunk.text)
    
    return chunks

到这里,基于文档结构分块的整体主函数框架就写完了。至于PPT、PDF、CSV、TXT这四种文件具体的分块逻辑,我们下面再逐个实现。

接下来我们来实现_chunk_single_document这个函数,我们把这个函数写在文件前面。

这个函数的入参是一个Document对象,返回值类型是List[Document]。我们的作用就是把清洗完成后的文档,按照文档结构完成分块,返回分块之后的文档列表。


我们先来处理 PPT 类型文档。

判断条件: 如果d.metadata.get("text_sections")可以获取到内容,那就说明这是 PPT 文档。我们直接调用_chunk_ppt_document,把当前的Document对象传入进去。

这个_chunk_ppt_document方法目前还不存在,我们现在来创建它。 这个函数的入参同样是Document类型,返回值依旧是List[Document]。接下来我们来编写 PPT 文档的处理逻辑。

只要能够拿到text_sections,我们就认定这是 PPT 类型文档。 我们把sections取出来:sections = d.metadata.get("text_sections")text_sections里面存放的就是 PPT 里面的小标题、各类结构分组,还有幻灯片备注这类信息。

接着我们提取幻灯片标题:slide_title = d.metadata.get("title")

然后我们定义一个辅助函数_extract_section_text,入参是字典类型的section,返回字符串。函数内部直接返回section.get("content"),作用就是从 PPT 解析结果当中提取每一小节的文本内容。

定义好这个辅助函数之后,我们遍历所有的sections,提取每一个小节的文本,存入texts列表。 同时我们要做过滤,如果遇到空的 PPT 页面,就直接过滤掉空内容。

我们之前定义过分块字符上限max_chunk_chars = 1500,分块必须要遵守这个大小限制。 如果一张幻灯片里面的内容特别多,就需要做拆分。我们把所有小节文本拼接得到full_text = "\n".join(texts)

判断:如果len(full_text) <= max_chunk_chars,说明当前幻灯片内容没有超限,可以直接作为一个完整分块。 我们构造元数据meta,里面设置chunk_index: 0,用来记录当前分块在原文档内的序号。 然后返回[Document(text=full_text, metadata=meta)],把文本和元数据一起封装返回。

如果幻灯片内容超过 1500 字符上限,那单张 PPT 就会拆分成多个分块。 我们定义groups列表用来存放分组结果,定义current列表作为临时缓存。 遍历texts里面每一段文本,把文本追加到current临时列表。 当current不为空的时候,就把临时列表拼接成字符串,保存到groups分组列表当中。

完成分组之后,我们需要把分组转换成最终的Document分块对象,初始化空列表chunks: List[Document]

遍历每一个分组group,拿到当前分组文本chunk_text做判断: 如果存在幻灯片标题,并且标题没有出现在当前分块文本里面,就把幻灯片标题拼接到分块文本前面:chunk_text = f"{slide_title}\n{chunk_text}"

然后构造元数据,设置section_index,用来标记这是当前幻灯片拆分出来的第几个分块。 把文本和元数据封装成Document对象,追加到chunks列表。 全部处理完成之后,返回chunks

python 复制代码
# 从 PPT 的解析字段提取文本
def _extract_section_text(section: dict) -> str:
    return section.get("content")

# 处理 PPT 类型文档分块
def _chunk_ppt_document(
        d: Document
) -> List[Document]:
    sections = d.metadata.get("text_sections")
    slide_title = d.metadata.get("title")

    texts = [_extract_section_text(s) for s in sections]
    texts = [t for t in texts if t]

    full_text = "\n".join(texts)
    if len(full_text) <= max_chunk_chars:
        meta = {
            "chunk_index": 0
        }
        return [Document(text=full_text, metadata=meta)]
    groups= []
    current = []

    for i, text in enumerate(texts):
        current.append(text)
        if current:
            groups.append("\n".join(current))

    chunks: List[Document] = []
    for idx, group in enumerate(groups):
        chunk_text = group
        if slide_title and slide_title not in chunk_text:
            chunk_text = f"{slide_title}\n{chunk_text}"
        meta = {
            "section_index": idx
        }

        chunks.append(Document(text=chunk_text, metadata=meta))
    return chunks


def _chunk_single_document(
        d: Document
)->List[Document]:
    # PPT
    if d.metadata.get("text_sections"):
        return _chunk_ppt_document(d)
    return [d]

到这里,PPT 类型文档的分块逻辑就全部写完了。

接下来我们编写测试入口来验证效果。 在if __name__ == "__main__":代码块中调用structure_chunk_documents。 注意,我们现在只想测试 PPT 文件。 我们在文档目录下新建一个文件夹,命名为structure_path,把 PPT 文件复制到这个文件夹内。 我们把config.structure_path指向这个目录,这样我们就可以单独测试 PPT 分块逻辑。后续写完 CSV、PDF 逻辑,也可以继续往这个目录放入对应文件,方便单独调试。

python 复制代码
if __name__ == "__main__":
    structure_chunk_documents(config.structure_path)

编写完测试代码,我们运行程序,运行完成之后,我们查看输出结果。

当前只处理 PPT 文件,一共分出 18 个分块。我们对照原始 PPT,总共有 18 张幻灯片。 因为每一张幻灯片的文本长度都没有超过 1500 字符上限,所以一张幻灯片对应一个分块,分块数量和幻灯片数量一一对应。

我们查看第 17 个分块,能够看到幻灯片标题 H17,还有幻灯片内部全部内容,内容完整准确。 到此,基于 PPT 文件格式的文档结构分块功能就实现完成了。

处理完 PPT 类型文档之后,接下来我们来实现 PDF 文档的基于文档结构分块逻辑。


我们在_chunk_single_document函数里面处理 PDF 的分支。判断当前文档是否为 PDF,就看能不能从文档元数据中获取到对应的属性,也就是d.metadata.get("page_label")。如果可以获取到,就代表这是 PDF 类型文档,我们直接调用_chunk_pdf_document方法,把当前Document对象传入进去。

现在_chunk_pdf_document这个方法还没有定义,我们来创建它。这个函数的作用就是按照文档结构去处理 PDF 类型文档。

参考前面 PPT 分块的代码写法,函数入参是Document类型对象d,返回值是List[Document]

在函数内部,首先获取文档文本内容:text = d.text。 如果文本为空,直接返回空列表。

接下来我们要记录 PDF 的页码信息,从元数据中取出page_label,构造基础元数据base_meta,把页码信息保存进去。

我们之前设置了分块字符上限max_chunk_chars = 1500,单块文本不能超过这个大小。 如果当前页面的文本总长度小于等于max_chunk_chars,说明整页内容可以作为一个完整分块。我们复制一份base_meta,设置chunk_index为 0,封装成Document对象返回。

如果页面文本长度超过上限,就需要在页面内部做二次切分。 我们使用正则表达式对文本进行切分,切分之后遍历每一段内容,调用strip()去除首尾空白,过滤掉空字符串,得到parts列表。

这里需要做一个判断: 如果切分之后得到的parts列表长度小于等于 1,说明刚才的正则切分没有生效,切分无效。这个时候我们要换另一套正则表达式重新做切分。 这个 PDF 页内切分的正则表达式比较复杂,我们直接把它定义到文件的顶部,作为全局常量,后面直接引用这个正则变量来执行split切分。

切分完成得到有效片段之后,接下来的逻辑参考 PPT 分块:

初始化空列表chunks: List[Document]用来存放最终分块结果。 遍历切分出来的每一个片段part,遍历的时候拿到索引idx。 复制一份基础元数据base_meta,调用update方法,往元数据里面追加chunk_index,标记当前片段在这一页内部的分块序号。 然后把片段文本和更新后的元数据封装成Document对象,追加到chunks列表。 全部遍历结束之后,返回chunks

python 复制代码
import re
from typing import List
from llama_index.core.schema import Document

# PDF页内次级结构切分正则:匹配编号(1.)、项目符号(●、‑、。)前面的位置
# 正向先行断言(?=...):只匹配分割位置,不会消费/删除匹配到的符号文本
_PDF_SUBSECTION_RE = re.compile(r"(?=\n(?:\d+\.|●|。|\-)\s*)")


def _chunk_pdf_document(d: Document) -> List[Document]:
    """
    PDF单页文档分块函数
    针对PDF解析出来的单页文本,优先按照列表编号、项目符号做语义切分;
    无列表结构时降级为按空行段落分割,保留页码元数据,输出多个Document分块。

    Args:
        d: 输入Document对象,代表PDF的**单页**内容,元数据必须包含page_label页码

    Returns:
        List[Document]: 分块后的文档列表,每个chunk携带页码、块序号元数据
    """
    # 取出当前PDF页面的原始文本内容
    text = d.text

    # 边界处理:页面文本为空,直接返回空列表,不生成无效分块
    if not text:
        return []

    # 从元数据获取PDF页码,page_label是pdf解析器输出的页码标记
    page_label = d.metadata.get("page_label")

    # 基础元数据模板,所有分块都会继承该页码信息
    base_meta = {
        "page_label": page_label
    }

    # 场景1:整页文本长度小于设定阈值,页面内容很短,无需拆分
    # 直接封装成单个chunk返回,chunk_index标记为0
    if len(text) <= max_chunk_chars:
        meta = dict(base_meta)
        meta["chunk_index"] = 0
        return [Document(text=text, metadata=meta)]

    # 场景2:页面文本较长,执行第一套分割策略
    # 使用PDF次级结构正则分割:在 换行+编号/项目符号 的位置切分
    # split返回分割片段;p.strip()去除片段首尾空白;if p.strip()过滤分割产生的空字符串
    parts = [p.strip() for p in _PDF_SUBSECTION_RE.split(text) if p.strip()]

    # 降级判断:如果正则分割后只剩下1个片段
    # 代表文本中没有识别到 1. / ● / - 这类列表标记,没有次级结构
    # 切换降级策略:按照空行(\n\s*\n)做段落分割,也就是普通段落切分
    if len(parts) <= 1:
        parts = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]

    # 存储最终输出分块结果
    chunks: List[Document] = []

    # 遍历所有分割得到的文本片段,组装成Document对象
    for idx, part in enumerate(parts):
        # 拷贝基础元数据,避免修改原始字典对象
        meta = dict(base_meta)
        # 追加当前页面内的分块序号,用于溯源当前页面第几个片段
        meta.update({
            "chunk_index": idx
        })
        # 将片段文本+元数据封装为Document,加入结果列表
        chunks.append(Document(text=part, metadata=meta))

    # 返回该PDF页面拆分完成后的全部分块
    return chunks

PDF 分块的逻辑写完之后,我们运行代码进行测试。 运行之后可以看到输出结果,一共分出 6 个分块。对照原始 PDF 文档,原始文档正好是 6 页。因为每一页的文本长度都没有超过 1500 字符上限,所以一页对应一个分块。

为了观察页内拆分的效果,我们修改分块上限,把max_chunk_chars从 1500 改成 100。 注意,清洗之后得到的文档数量依旧是 6 个,不会发生变化,但是分块数量会发生改变。 再次执行代码,可以看到分块数量变成了 26 块。

原始 PDF 只有 6 页,但是我们把分块上限设置得很小,就会触发页面内部的切分,产生大量分块。 这里要提醒大家,这个分块上限并不是固定写死的,需要结合自己数据集的实际业务场景,去设置一个合理的值。

到此为止,PDF 类型文档基于文档结构的分块逻辑就全部实现完成了。


处理完 PDF 类型文档之后,接下来我们来处理 CSV 类型文档,依靠文档本身的结构特性完成分块。

我们在_chunk_single_document函数中判断 CSV 类型文档。 判断条件:通过d.metadata.get("file_name")获取文件名,如果文件名以.csv结尾,就判定为 CSV 文档,调用_chunk_csv_document方法,把当前Document对象传入。

现在_chunk_csv_document方法还没有定义,我们来创建这个函数。函数作用:处理 CSV 类型文档,按照文档结构完成分块。 函数入参为Document类型对象d,返回值类型为List[Document]

函数内部第一步,获取文档文本内容:text = d.text。 接着做判空处理,如果文本为空,直接返回空列表。

我们来看 CSV 文件本身的格式特点: CSV 文件包含表头 header数据体两部分。表头是第一行,记录字段名称;后续全部都是数据行。 我们把测试用的 CSV 文件放到结构分块的测试目录中,结合 CSV 格式特性来编写代码逻辑。

首先将文本按行切分,得到所有行的列表,同时对每一行做strip()处理,过滤掉空行。

为了读取 CSV 的表头,我们单独封装一个工具函数_read_csv_header。 这个函数需要读取原始文件,提取 CSV 的表头行,返回字符串类型的表头。 函数内部使用with open()打开文件,指定newline=""、编码utf‑8,需要提前导入csv模块。 通过csv.reader读取文件,调用next()读取第一行,也就是表头,再把表头用逗号拼接成字符串返回。

回到_chunk_csv_document主逻辑中,从文档元数据取出原始文件路径:file_path = d.metadata.get("file_path")。 调用_read_csv_header(file_path)获取 CSV 表头header

拿到所有行之后,除去表头,剩下的全部都是数据行,也就是data_lines = lines[1:]

初始化空列表chunks: List[Document],用来存放分块之后的文档对象。 遍历每一条数据行,遍历的时候拿到索引idx。 构造分块文本chunk_text:把表头和当前这一条数据行拼接在一起。

这里的逻辑:每一个分块都会带上完整的 CSV 表头,再附带一条数据行,每一个分块都是一个小型完整的 CSV 片段。

然后构造元数据,设置chunk_index = idx,用来标记当前分块在原文档中的序号。 把chunk_text和元数据封装成Document对象,追加到chunks列表当中。 全部遍历完成,返回chunks

到此,CSV 文档的分块方法就实现完成了。

python 复制代码
import csv
from typing import List
from llama_index.core.schema import Document


def _read_csv_header(path: str) -> str:
    """
    读取CSV文件的表头行,返回逗号拼接的表头字符串

    Args:
        path: csv文件本地路径

    Returns:
        str: 表头行,格式如"列名1,列名2,列名3"
    """
    # newline="" 是csv模块推荐的打开方式,避免换行符解析异常
    # encoding="utf‑8‑sig" 兼容带BOM的utf‑8 csv(Excel导出的csv常见)
    with open(path, newline="", encoding="utf‑8‑sig") as f:
        # 读取第一行,读取失败返回None
        row = next(csv.reader(f), None)
        # 将表头列表拼接成逗号分隔字符串,用于后续和数据行组合
        return ",".join(row)


def _chunk_csv_document(d: Document) -> List[Document]:
    """
    CSV文档分块函数
    对CSV文本做行级分块:**每一条数据行都带上完整表头**,生成独立Document
    适配表格类RAG,保证每条数据块都知道字段含义,避免只拿到数据不知道列名。

    Args:
        d: Document对象,内容为CSV的全部文本,元数据需要包含file_path文件路径

    Returns:
        List[Document]: 分块后的文档列表,每一个chunk = 表头 + 单行数据
    """
    # 获取csv文档原始文本
    text = d.text

    # 空文本直接返回空列表,避免生成无效分块
    if not text:
        return []

    # 按换行分割文本,去除每行首尾空白;过滤掉空行
    lines = [line.strip() for line in text.splitlines() if line.strip()]

    # 从文档元数据获取csv文件路径,用来读取真实表头
    path = d.metadata.get("file_path")

    # 调用工具函数读取csv文件的表头字符串
    header = _read_csv_header(path)

    # 取出所有行(包含表头行+全部数据行)
    data_lines = lines[0:]

    # 存储最终分块结果
    chunks: List[Document] = []

    # 遍历每一行,构建chunk:表头 + 当前行数据
    for idx, line in enumerate(data_lines):
        # 拼接:表头换行,然后是单行数据,形成完整一行表格记录
        chunk_text = f"{header}\n{line}"
        # 设置块序号元数据,标记这是第几条数据
        meta = {"chunk_index": idx}
        # 封装Document对象加入结果列表
        chunks.append(Document(text=chunk_text, metadata=meta))

    return chunks

接下来运行代码做测试,测试目录只放入 CSV 文件。执行之后可以看到输出结果,一共分出 5 个分块。 每一个分块内部都保留完整的 CSV 表头,搭配一条对应的数据行。

这里需要提醒大家:当前代码是一行数据生成一个分块。如果觉得分块粒度太细碎,可以自行修改逻辑。 不需要每次只拼接一行,你可以调整逻辑,一次拼接多行数据,比如一次性拼接 200 行、500 行数据,再生成一个分块,根据自己数据集的实际情况灵活调整。

完成以上代码,CSV 类型文档基于文档结构的分块逻辑就全部实现完毕。


前面我们已经实现完 CSV 类型文件的结构分块,接下来我们来看最后一种文件类型:TXT 纯文本文件的结构分块。

我们先来思考一个问题:TXT 文件本身存在文档结构吗? TXT 属于纯文本格式,没有内置的标记,没有标题、没有表头、没有章节标记,全部都是平铺的文本内容,没有可以识别的结构化信息。

所以对于 TXT 文件来说,使用基于文档结构的分块是不太合适的。因为它本身就不存在可供解析的文档结构,强行使用这套分块逻辑没有实际意义。

这是我们要处理的第 4 类文档,也就是 TXT 类型文档,也包含其他无法识别结构的文档。 既然 TXT 没有明显的文档结构,在这套基于文档结构的分块逻辑里面,我们就不对它做额外分块处理。 如果需要对 TXT 做分块,更适合使用我们之前的方案:固定大小分块、句子分块,或者语义分块,这些方式会比文档结构分块效果更好。

为了保持代码逻辑的连贯性,我们这里直接做兜底处理:直接返回原始加载清洗完成后的 Document 对象,不执行任何拆分操作。

我们来测试一下效果。把测试用的 TXT 文件复制到测试目录,运行代码。 原始经过数据加载之后,TXT 文档无论内容多大,都只会是一个文档对象。经过我们这套文档结构分块处理之后,输出依旧还是这一个文档对象,不会产生新的分块。

再次强调:如果业务上确实需要对 TXT 做拆分,建议使用前面学过的其他分块策略。

structureChunk.py

python 复制代码
import re
import csv
from typing import List
from llama_index.core.schema import Document
import util
import config

# 单块最大字符阈值,超过该长度则需要做分块处理
max_chunk_chars = 1500

# PDF页内次级结构切分正则
# 正向先行断言(?=...):只匹配分割位置,不会消费、删除匹配到的编号/符号文本
# 匹配位置:换行之后出现 数字加点 / ● / 。 / - ,后面可跟任意空白
_PDF_SUBSECTION_RE = re.compile(r"(?=\n(?:\d+\.|●|。|\-)\s*)")


def _extract_section_text(section: dict) -> str:
    """
    从PPT解析出来的section字典中提取内容文本

    Args:
        section: PPT单页内的片段字典,包含content字段

    Returns:
        str: 片段的文本内容,不存在则返回None
    """
    return section.get("content")


def _chunk_ppt_document(d: Document) -> List[Document]:
    """
    PPT文档分块处理函数
    PPT解析结果会把一页拆成多个text_sections片段;
    优先按页面内片段做分组,页面总文本较短则不拆分;
    每个分块会自动带上幻灯片标题。

    Args:
        d: Document对象,PPT单页文档
           metadata包含:text_sections(页面片段列表)、title(幻灯片标题)

    Returns:
        List[Document]: 分块后的文档列表
    """
    # 获取PPT页面内的文本片段列表
    sections = d.metadata.get("text_sections")
    # 获取当前幻灯片标题
    slide_title = d.metadata.get("title")

    # 遍历所有片段,提取文本内容
    texts = [_extract_section_text(s) for s in sections]
    # 过滤掉空的片段
    texts = [t for t in texts if t]

    # 将所有片段拼接成完整页面文本
    full_text = "\n".join(texts)

    # 如果整页总字符小于阈值,不需要拆分,直接返回单个块
    if len(full_text) <= max_chunk_chars:
        meta = {
            "chunk_index": 0
        }
        return [Document(text=full_text, metadata=meta)]

    groups = []
    current = []
    for i, text in enumerate(texts):
        current.append(text)
        # 【注意:当前代码逻辑:每遍历一个片段就生成一个分组】
        # 也就是每个PPT内部片段单独作为一个chunk,没有做字符聚合
        if current:
            groups.append("\n".join(current))

    # 组装最终分块结果
    chunks: List[Document] = []
    for idx, group in enumerate(groups):
        chunk_text = group
        # 如果幻灯片标题存在,并且标题不在当前块文本内,则前置加上标题
        if slide_title and slide_title not in chunk_text:
            chunk_text = f"{slide_title}\n{chunk_text}"
        meta = {
            "section_index": idx
        }
        chunks.append(Document(text=chunk_text, metadata=meta))

    return chunks


def _chunk_pdf_document(d: Document) -> List[Document]:
    """
    PDF单页文档分块函数
    优先按照编号、项目符号做语义切分;找不到列表标记则降级为按空行段落分割;
    保留页码元数据,输出页内分块。

    Args:
        d: Document对象,PDF单页文档,metadata包含page_label页码

    Returns:
        List[Document]: 分块后的文档列表
    """
    text = d.text
    # 空文本直接返回空列表
    if not text:
        return []

    # 获取PDF页码
    page_label = d.metadata.get("page_label")
    # 基础元数据模板,所有分块继承页码信息
    base_meta = {
        "page_label": page_label
    }

    # 页面总字符小于阈值,无需拆分,返回单块
    if len(text) <= max_chunk_chars:
        meta = dict(base_meta)
        meta["chunk_index"] = 0
        return [Document(text=text, metadata=meta)]

    # 策略1:使用PDF次级结构正则分割,按编号/项目符号切分
    parts = [p.strip() for p in _PDF_SUBSECTION_RE.split(text) if p.strip()]

    # 降级判断:分割后只有1段,说明没有识别到列表标记
    # 切换策略:按照空行段落分割
    if len(parts) <= 1:
        parts = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]

    chunks: List[Document] = []
    for idx, part in enumerate(parts):
        meta = dict(base_meta)
        meta.update({
            "chunk_index": idx
        })
        chunks.append(Document(text=part, metadata=meta))

    return chunks


def _read_csv_header(path: str) -> str:
    """
    读取CSV文件的表头,返回逗号拼接的表头字符串

    Args:
        path: csv文件本地路径

    Returns:
        str: 表头字符串,例:"姓名,年龄,地址"
    """
    # newline="" csv模块标准打开方式,防止换行解析错乱
    # encoding="utf‑8‑sig" 兼容Excel导出带BOM的CSV文件
    with open(path, newline="", encoding="utf‑8‑sig") as f:
        # 读取第一行表头,无内容返回None
        row = next(csv.reader(f), None)
        # 列表拼接为逗号分隔字符串
        return ",".join(row)


def _chunk_csv_document(d: Document) -> List[Document]:
    """
    CSV文档分块函数
    每一条数据行都带上完整表头,保证RAG检索时模型知道字段含义。

    Args:
        d: Document对象,内容为CSV全部文本,metadata需要包含file_path

    Returns:
        List[Document]: 分块列表,每个chunk = 表头 + 单行数据
    """
    text = d.text
    if not text:
        return []

    # 按换行分割,去除每行首尾空白,过滤空行
    lines = [line.strip() for line in text.splitlines() if line.strip()]

    # 获取文件路径,用于读取原始csv表头
    path = d.metadata.get("file_path")
    header = _read_csv_header(path)

    # 取全部行(包含表头行)
    data_lines = lines[0:]

    chunks: List[Document] = []
    for idx, line in enumerate(data_lines):
        # 拼接表头 + 当前数据行
        chunk_text = f"{header}\n{line}"
        meta = {"chunk_index": idx}
        chunks.append(Document(text=chunk_text, metadata=meta))

    return chunks


def _chunk_single_document(d: Document) -> List[Document]:
    """
    单文档分发分块处理器
    根据文档元数据特征,判断文档类型,调用对应分块函数
    优先级:PPT → PDF → CSV → 普通txt直接返回原文档

    Args:
        d: 原始Document对象

    Returns:
        List[Document]: 该文档分块之后的结果列表
    """
    # 1. PPT文档:metadata存在text_sections字段
    if d.metadata.get("text_sections"):
        return _chunk_ppt_document(d)

    # 2. PDF文档:metadata存在page_label页码字段
    if d.metadata.get("page_label"):
        return _chunk_pdf_document(d)

    # 3. CSV文档:文件名后缀为.csv
    if d.metadata.get("file_name").endswith(".csv"):
        return _chunk_csv_document(d)

    # 4. txt等普通文档,不做结构分块,原样返回
    return [d]


def structure_chunk_documents(input_str: str) -> List[Document]:
    """
    基于文档原生结构的分块入口函数
    先清洗原始输入,遍历每一份文档,调用单文档分块逻辑,汇总全部分块。

    Args:
        input_str: 文件路径字符串,传入util做格式清洗解析

    Returns:
        List[Document]: 全部分块后的Document列表
    """
    # 1. 调用清洗工具,得到原始Document列表
    documents = util.clean_all_formats(input_str)

    # 2. 遍历每一份文档,执行分块,汇总结果
    chunks: List[Document] = []
    for d in documents:
        doc_chunks = _chunk_single_document(d)
        chunks.extend(doc_chunks)

    # 3. 调试打印输出所有分块信息,方便查看分块效果
    for i, chunk in enumerate(chunks):
        print(f"第{i+1}个分块")
        print(chunk.metadata)
        print(chunk.text)

    return chunks


if __name__ == "__main__":
    """程序入口,执行结构分块测试"""
    structure_chunk_documents(config.structure_path)

现实当中还有 Word、Markdown 等其他文件格式,这些文件同样具备自身的文档结构。整体的设计原理是相通的,仅仅是代码解析的细节会存在差异。

基于 LLM 的分块

我们继续来实现咱们文本分块的最后一个分块方式,也就是基于大语言模型的分块

基于 LLM 的分块 是指利用大语言模型来智能地确定文本切分边界的方法。它不依赖固定分隔符、字符数或预定义规则,而是通过向LLM提供提示词,让模型根据语义理解、主题连贯性、逻辑结构等因素,自主决定在哪里切分以及如何合并句子。

但是在整个 RAG 流程中,我们最后本身就有大模型生成环节,那我们还有没有必要,在知识库构建阶段,再额外调用一次大模型做分块?

这个问题仁者见仁、智者见智。 如果前面讲过的各类分块方式,已经能够满足我们的业务分块场景、解决实际问题,那就没必要调用大模型做分块。 原因很简单:调用大模型会产生网络开销,整体成本会比较高。尤其是使用非免费模型时,还会产生额外费用。

但如果我们的数据集,使用前面几种分块方式效果不理想,那引入大语言模型分块就很有必要。 所以最终还是要看我们 RAG 的原始数据集,做到具体问题具体分析,结合数据集本身的结构来做抉择。如果传统分块方案效果不好,就可以考虑使用大模型分块。

接下来,除了讲解大模型分块的原理,我们还要演示如何借助LlamaIndex框架,通过它的 SDK 来接入大模型。

我们打开阿里云百炼平台。

AI模型市场 - 文本图片视频音频模型一站调用- 千问AI平台https://www.qianwenai.com/models?search=qwen-turbo 进入模型页面,可以看到平台的模型资源十分丰富,包含文本模型、语音模型、视觉模型、全模态模型、向量模型等。 我们可以简单浏览各类模型能力:文本模型支持对话;语音模型可以做语音识别、语音合成。 在 RAG 项目里,我们重点使用文本模型,用来完成知识库构建、文档分块这类工作。

我们选用qwen‑turbo模型,在搜索框检索就可以找到。点开可以查看模型介绍,它属于通义千问系列模型。

选中模型之后,可以开启免费额度。平台会给出提示:请勿用于生产环境。免费额度用完之后模型就无法使用。如果放到生产环境,额度耗尽会造成业务故障。我们是学习,所以可以放心使用。点击启用,就可以开启这个免费模型。

可以看到模型会提供一定的调用额度,同时附带过期时间。通义千问的生态做得很完善,除了付费模型,也提供免费模型。我们可以利用免费模型完成项目开发,节省开发成本。

模型选定之后,还需要创建调用密钥。页面上找到API Key入口,点击进入。右上角有创建 API Key 的按钮,点击即可生成密钥。后续我们就是拿着这个密钥去调用大模型接口。

我这里已经提前创建好了密钥。密钥不会在页面展示完整内容,复制出来之后,才能看到完整原始密钥。

模型选好、API 密钥准备完毕,接下来我们安装项目所需依赖。 打开终端,我们需要安装两个依赖包: 第一个是llama‑index‑llms‑dashscope,这是 LlamaIndex 对接通义千问的 SDK 依赖; 第二个是python‑dotenv。我们的 API 密钥属于敏感信息,不建议直接写在 Python 代码中,我们把密钥存到.env配置文件,python‑dotenv就是用来读取环境配置文件的第三方库。

bash 复制代码
pip install llama-index-llms-dashscope -i https://mirrors.aliyun.com/pypi/simple/

pip install python-dotenv -i https://mirrors.aliyun.com/pypi/simple/

执行安装命令,把两个依赖安装完成,接下来就可以编写对应的业务代码。


大模型功能封装

引入完大模型之后,接下来我们就在代码层面对它做封装。

那我们该如何进行封装呢? 首先创建一个.env文件。我们希望把 API 密钥保存在这个文件里面,这样就不用把密钥硬编码写在代码当中。

把我们的密钥复制粘贴到.env文件中,定义变量名DASHSCOPE_API_KEY,把密钥赋值给这个变量。

有了密钥之后,我们需要在配置文件里读取这个变量,后续调用模型的时候,直接取用配置文件里的变量即可。

这里要注意读取的前提: 必须先加载.env文件。 我们在配置文件的开头调用load_dotenv()方法,加载项目根目录下的.env文件。加载完成之后,再通过os.getenv()获取环境变量里的DASHSCOPE_API_KEY。

完成环境变量的加载与读取之后,接下来就可以编写封装代码。 我们把调用通义千问的相关代码封装到util.py工具文件中。 config.py负责存放全部项目配置,util.py作为工具模块,存放各类工具函数,所以我们把大模型客户端的初始化逻辑放到这里。

config.py

python 复制代码
# 加载项目根目录下的 .env 文件
load_dotenv(base_path / ".env")

util.py里面,我们专门写一个函数,用来初始化通义千问的客户端实例。 我们先初始化出客户端对象,后续通过这个客户端对象来访问大模型。

我们定义函数create_qwen_turbo_llm,这个函数专门用来创建通义千问的客户端。如果后续要切换其他模型,只需要改写对应创建逻辑即可。

函数需要设置入参,返回值类型是DashScope,这里记得要引入对应的DashScope类。

函数内部第一步:获取 API Key。 直接从config配置模块读取config.dashscope_api_key,这个就是我们前面从.env文件读取好的密钥。

拿到密钥之后,第二步,根据密钥创建客户端实例。 直接 return 返回DashScope对象。我们来看它的构造参数: 其中model_name是必填参数。它的默认模型是qwen‑max,而我们要用的是qwen‑turbo,所以需要手动指定。 我们使用枚举DashScopeGenerationModels.QWEN_TURBO来指定模型,不需要手写字符串。

除此之外,还需要传入api_key,也就是我们获取到的密钥。 另外还有两个非常关键的参数:temperaturemax_tokens

temperature: 用来控制大模型输出结果的随机性。数值越大,输出随机性越高;数值越小,输出越稳定。我们给它设置一个适中的默认值0.5,类型为浮点型float

max_tokens: 代表模型单次返回内容最大的 token 数量,大模型输出是有长度上限的。我们设置默认值为1024,类型为整型int

这里的0.51024只是默认参数,不是固定死的。我们把这两个参数作为函数入参,设置默认值。后续调用这个函数创建客户端实例的时候,如果不传入这两个参数,就会使用默认值;如果需要调整,也可以传入自定义数值覆盖默认。

temperaturemax_tokens传入构造函数,到这里,大模型客户端实例就创建完成了。

util.py

python 复制代码
from typing import Generator
from llama_index.llms.dashscope import DashScope, DashScopeGenerationModels
from llama_index.core.llms import ChatMessage, MessageRole
import config


def create_qwen_turbo_llm(
    temperature: float = 0.5,
    max_tokens: int = 1024,
) -> DashScope:
    """
    创建通义千问‑turbo 的LLM客户端实例
    封装DashScope客户端,读取配置中的API Key,配置模型参数

    Args:
        temperature: 温度系数,控制生成随机性;0~1,越大越随机
        max_tokens: 模型最大输出token数量

    Returns:
        DashScope: llama‑index封装的通义千问LLM对象
    """
    # 从config读取通义千问API Key
    key = config.dashscope_api_key

    # 初始化DashScope客户端,指定模型为qwen‑turbo
    return DashScope(
        model_name=DashScopeGenerationModels.QWEN_TURBO,
        api_key=key,
        temperature=temperature,
        max_tokens=max_tokens,
    )



# 全局LLM实例,程序启动时初始化一次,全局复用
llm = create_qwen_turbo_llm()

客户端实例创建完成之后,接下来我们来实现对应的业务功能。

首先实现单轮对话功能。我们希望在代码里实现:输入一个问题,让通义千问给出解答。

我们定义一个函数complete,入参为prompt字符串,返回值也是字符串。 函数内部,使用已经初始化好的大模型客户端实例,调用complete方法。传入参数prompt,也就是我们传给大模型的问题。

该方法返回的是CompletionResponse对象,我们从返回对象中取出text字段,也就是模型输出的文本内容,直接返回即可。这样基础的单轮对话就完成了。

LlamaIndex CompletionResponse 小卡片

来源:llama_index.core.llms.CompletionResponse 对应:llm.complete(prompt) 的返回对象


📇 CompletionResponse 卡片

作用:非流式调用 LLM 补全接口后返回的完整响应对象,存放模型输出、元信息。

字段 说明
.text 最常用,模型输出完整文本字符串 ✅
.raw 底层原始 API 返回字典(dashscope/openai 原生响应)
.additional_kwargs 额外参数,token 消耗、模型信息等
.delta 非流式下永远为 None;delta 只用于流式 chunk

✨ 基础用法

python 复制代码
from llama_index.core.llms import CompletionResponse

resp: CompletionResponse = llm.complete("你好")
print(resp.text)          # 获取完整回答文本
print(resp.raw)           # 原始接口返回
print(resp.additional_kwargs) # token使用情况
python 复制代码
def complete(prompt: str) -> str:
    """
    普通单次补全对话(非流式)
    输入文本prompt,等待模型完整返回结果,返回完整字符串
    注意:该接口不支持system系统提示词

    Args:
        prompt: 用户输入提示词

    Returns:
        str: LLM完整回复文本
    """
    # llm.complete 调用补全接口,返回CompletionResponse对象,.text取出回复内容
    return llm.complete(prompt=prompt).text

我们来做测试。在util.py中编写测试调用,传入提示词:"介绍一下什么是 RAG,包括它的起源、发展和现状"。执行代码,可以看到模型完整返回了 RAG 相关的回答,包含起源、发展、现状、优势、未来展望等内容,回答效果是合格的。

**但是大家会发现一个问题:**执行之后需要等待比较久的时间,模型把全部内容生成完毕,才一次性把完整结果返回给客户端。这种等待体验不太好。

针对这个问题,我们引入流式返回(stream)。 前面的方式属于非流式,要等全部内容生成完成才返回。流式返回则是模型生成一点,就返回一点,用户可以边生成边阅读,不需要等待全部结果输出完毕。

我们先在平台上体验流式效果,模型会逐字输出内容,输出完成的同时,用户也已经阅读完毕,体验会好很多。

接下来我们在代码里实现流式对话接口,新建函数stream_complete。 因为是流式输出,函数不能返回固定字符串,返回类型为CompletionResponseGen【也就是CGenerator[ompletionResponse, None, Node】,需要从typing导入Generator

函数内部调用大模型客户端的stream_complete方法,传入prompt参数。 该接口返回的是迭代器,我们遍历每一个返回的CompletionResponse片段,提取每一段的文本内容,通过生成器yield返回。

python 复制代码
def stream_complete(prompt: str) -> Generator[str, None, None]:
    """
    单次补全流式输出
    调用stream_complete流式接口,逐个yield返回文本片段,实现打字机效果
    注意:该接口不支持system系统提示词

    Args:
        prompt: 用户输入提示词

    Yields:
        str: 模型返回的增量文本片段delta
    """
    # 遍历流式返回的chunk对象
    for chunk in llm.stream_complete(prompt=prompt):
        # chunk.delta 为本次新增的文本片段,过滤空delta
        if chunk.delta:
            yield chunk.delta



if __name__ == "__main__":
    print("===== 流式输出测试 =====")
    # 遍历生成器,逐块输出
    for delta in stream_complete("介绍一下什么是RAG"):
        # end="" 不换行;flush=True 强制缓冲区立刻输出,否则会攒一批才打印
        print(delta, end="", flush=True)
    # 全部输出完成,补一个换行,避免提示符粘在输出末尾
    print()

测试流式调用的时候,打印输出不能使用普通的print。我们循环遍历生成器返回的每一段文本,打印的时候设置end="",不自动换行,同时开启flush=True强制立刻输出。全部输出结束之后,再打印一个换行。

运行测试,可以看到内容逐字输出,不再需要长时间等待一次性返回全部结果。

实现完单轮对话之后,接下来实现多轮对话 。 多轮对话,就是我们可以同时传入系统提示词用户提示词,用来约束大模型的回答边界,限定回答的格式与范围。

我们先在平台直观感受系统提示词的作用。 比如提问:"陕西的省会是哪里?",不加系统提示词时,模型除了给出答案西安,还会附带一大段关于西安的拓展介绍。 对于人阅读来说没问题,但如果是程序做解析处理,这种额外拓展内容会带来麻烦,输出结果不可控。

我们加上系统提示词约束:"只回答城市名称,不要额外扩展内容",再发送同样的问题。模型就只会返回简短的 "西安",输出结果简洁可控,便于代码处理。 这里,"陕西的省会是哪里?" 属于用户提示词 ;"只回答城市名称,不要额外扩展内容" 属于系统提示词。通过二者组合,我们就可以约束模型输出。

接下来在代码实现多轮对话函数chat。 函数接收两个字符串参数:user_prompt用户提示词、system_prompt系统提示词,返回字符串,先实现非流式版本。

构造消息列表messages,消息是字典格式,包含rolecontent两个字段。 第一条消息,role设置为MessageRole.SYSTEMcontent填入system_prompt; 第二条消息,role设置为MessageRole.USERcontent填入user_prompt

把构造好的messages传入大模型的chat接口,拿到响应之后,取出响应的content字段返回。

python 复制代码
def chat(
    user_prompt: str,
    sys_prompt: str,
) -> str:
    """
    多轮对话(非流式),支持系统提示词
    构造system+user消息列表,调用chat接口,返回完整回复
    消息顺序:system必须放在最前面

    Args:
        user_prompt: 用户提问内容
        sys_prompt: 系统提示词,设定AI角色、行为约束

    Returns:
        str: LLM完整回复文本
    """
    # ✅修正消息顺序:SYSTEM放在列表第一位
    messages = [
        ChatMessage(role=MessageRole.SYSTEM, content=sys_prompt),
        ChatMessage(role=MessageRole.USER, content=user_prompt),
    ]
    # 调用chat接口,获取回复消息,取出content文本
    return llm.chat(messages).message.content

做测试:传入用户提示词 "陕西的省会是哪个城市",系统提示词 "你只需要回答城市名"。执行之后模型就只会输出 "西安",回答边界被成功约束。

同样,我们也可以实现流式版本的多轮对话stream_chat,逻辑和流式单轮对话类似,构造相同的messages消息列表,调用模型流式聊天接口,遍历返回片段,用生成器逐段输出文本。

python 复制代码
def stream_chat(
    user_prompt: str,
    sys_prompt: str,
) -> Generator[str, None, None]:
    """
    多轮对话流式输出,支持系统提示词
    构造system+user消息,流式逐个返回文本片段
    消息顺序:system必须放在最前面

    Args:
        user_prompt: 用户提问内容
        sys_prompt: 系统提示词,设定AI角色、行为约束

    Yields:
        str: 模型返回的增量文本片段delta
    """
    # ✅修正消息顺序:SYSTEM放在列表第一位
    messages = [
        ChatMessage(role=MessageRole.SYSTEM, content=sys_prompt),
        ChatMessage(role=MessageRole.USER, content=user_prompt),
    ]
    # 遍历流式返回的chunk
    for chunk in llm.stream_chat(messages):
        if chunk.delta:
            yield chunk.delta

到此为止,我们完成了大模型 SDK 的基础封装:包含单轮非流式、单轮流式、多轮非流式、多轮流式对话。 这些封装好的能力,后面做大模型分块,以及 RAG 的大模型生成环节都会用到。


接下来我们来实现基于大模型的分块。前面我们已经完成了大模型基础功能的封装,包含普通单轮对话、流式对话、多轮对话等能力。现在我们可以直接使用这些工具,开始编写大模型分块的代码。

我们在PyCharm中新建文件,命名为llmChunk.py,专门用来实现LLM文本智能分块功能。

首先进行模块导入:

  • 第一,导入正则表达式re,后续我们需要通过正则规则,分割大模型返回的字符串结果;

  • 第二,从类型注解中导入List

  • 第三,从llama_index.core.schema导入Document文档对象;

  • 最后导入我们项目的config配置文件和util工具文件。

完成导入之后,我们需要定义一个全局特殊分隔标识 。大家要注意:无论大模型输出多少内容,最终返回给程序的永远是字符串str类型。我们无法直接区分分块边界,所以需要自定义唯一特殊标记,让大模型按照我们的规则,在每一个分块之间插入标识,方便后续代码切割。

我们定义分隔常量:CHUNK_DELIMITER = "===CHUNK==="

紧接着编写系统提示词 ,系统提示词直接决定大模型的分块效果,是整个功能的核心。我们定义SYSTEM_PROMPT,内容如下:

首先给大模型设定身份:你是RAG知识库文档分块助手。

核心任务:请把用户提供的文档,切分为多个语义完整、适合向量检索的文本分块。

同时制定五条严格规则:

  • 第一,每一个分块只围绕单个独立主题

  • 第二,尽量不拆分完整段落、不拆分完整句子,最大程度保证语义连贯性;

  • 第三,严格保留原始文本语义,不修改、不删减原文内容;

  • 第四,不同分块之间,统一使用我们定义的CHUNK_DELIMITER特殊标识分隔;

  • 第五,只输出分块正文内容,不输出任何多余解释、多余话术,避免程序解析出错。

写完系统提示词之后,我们需要封装一个解析大模型返回结果的工具方法。

定义方法_parse_llm_chunks,入参是大模型返回的响应字符串,返回值是字符串列表List[str]

方法内部通过正则re.split,匹配换行+自定义分隔符+换行的规则,对返回的完整字符串进行切割。切割完成后,遍历所有分块,执行strip()去除首尾空字符和多余空格,过滤无效空块。

**同时做容错判断:**如果切割之后没有得到任何分块,说明大模型没有按照规则分隔,我们直接将原始完整文本作为唯一分块返回,保证程序不报错。

解析方法完成后,我们继续封装调用大模型执行分块 的核心方法_llm_split_text

该方法传入两个参数:待分块的原始文本text: str、单块最大字符数max_chunk_chars: int,返回值为文本分块列表。

方法内部拼接用户提示词,告知大模型:将当前文档分块,每个分块字符数量不超过设定的最大值,随后传入完整原文。

接着调用我们之前封装好的util.chat多轮对话方法,传入拼接好的用户提示词和固定的系统提示词,获取大模型返回的结果。

最后调用刚刚编写的_parse_llm_chunks解析方法,对返回结果进行切割解析,得到标准文本分块,存入列表并返回。

至此,文本级别的LLM分块逻辑完成。接下来我们封装文档级批量分块入口方法 ,也就是外部调用的主方法llm_chunk_documents

该方法入参为目录路径input_str,返回值为List[Document],和我们前面所有分块方式的返回格式保持统一,保证项目接口一致。

方法执行流程和之前分块逻辑完全对齐:

  • 第一步,调用util.clean_all_formats(input_str),读取并清洗目录下的所有文档,得到清洗后的documents文档列表;

  • 第二步,初始化空列表chunks,用来存储最终所有分块后的Document对象;

  • 第三步,遍历每一个清洗后的文档,取出文档原始文本text

  • 第四步,调用_llm_split_text方法,对单篇文档执行LLM智能分块,得到多个文本子块;

  • 第五步,遍历所有子块,手动补充分块元数据 ,记录chunk_index,标记当前子块是原文档的第几个分块;

  • 第六步,将每一个文本子块和元数据,重新封装为全新的Document对象,追加到结果列表中。

所有文档处理完成后,我们添加调试打印逻辑:循环遍历所有分块,依次打印分块序号、分块元数据、分块文本内容,方便我们直观查看分块效果。

最后返回完整的分块结果列表chunks

llmChunk.py

python 复制代码
import re
from typing import List
from llama_index.core.schema import Document
import config
import util

# 自定义分隔标记:用于LLM输出分块之间的分割标识
# LLM输出的各个分块会用这个字符串隔开,后续正则以此做分割
CHUNK_DELIMITER = "===CHUNK==="

# 系统提示词:定义LLM作为文档分块助手的角色与输出规则
SYSTEM_PROMPT = f"""你是RAG知识库文档分块助手。请把用户提供的文档切分为多个语义完整、适合向量检索的分块。
要求:
1. 每个分块围绕一个独立的主题
2. 尽量不要拆分段落,不要拆分句子
3. 保持原始文本的语义不变
4. 各个分块之间用{CHUNK_DELIMITER}分隔开
5. 只输出分块的正文,不要额外的信息
"""


def _parse_llm_chunks(response: str) -> List[str]:
    """
    解析LLM返回的字符串,按自定义分隔符拆分成文本分块列表
    处理分隔符前后可能存在的换行符,同时去除每个块首尾空白

    Args:
        response: LLM返回的完整响应文本,包含多个分块,用CHUNK_DELIMITER隔开

    Returns:
        List[str]: 拆分完成后的文本分块列表
    """
    # 正则分割:匹配分隔符,允许分隔符前后存在0或1个换行符
    # \n? 代表可选换行,兼容LLM输出:===CHUNK=== 或者 \n===CHUNK===\n 的情况
    parts = re.split(rf"\n?{CHUNK_DELIMITER}\n?", response.strip())
    # 对每一块去除首尾空白字符
    chunks = [part.strip() for part in parts]

    # 边界处理:如果分割后得到空列表,直接把原始响应作为唯一分块
    if not chunks:
        chunks = [response.strip()]

    return chunks


def _llm_split_text(text: str, max_chunk_chars: int) -> List[str]:
    """
    调用LLM对单段原始文本做语义分块,返回拆分后的文本列表

    Args:
        text: 待分块的原始文档文本
        max_chunk_chars: 每个分块建议最大字符数

    Returns:
        List[str]: LLM拆分得到的文本分块列表
    """
    # 构造用户提示词:告知LLM分块字符上限,传入待处理文档内容
    USER_PROMPT = (
        f"请将以下文档分块,每个块不超过{max_chunk_chars}字符:\n\n"
        f"{text}"
    )

    all_chunks: List[str] = []
    # 调用util封装的chat接口,传入用户prompt+系统提示词,获取LLM完整返回
    result = util.chat(USER_PROMPT, SYSTEM_PROMPT)
    # 解析LLM输出,得到文本分块,并追加到结果列表
    all_chunks.extend(_parse_llm_chunks(result))

    return all_chunks


def llm_chunk_documents(
    input_str: str,
    max_chunk_chars: int = 500
) -> List[Document]:
    """
    对外主入口:读取指定目录下全部文档,做清洗,再通过LLM做语义分块,返回Document对象列表

    Args:
        input_str: 文档文件夹路径,传给util.clean_all_formats读取文档
        max_chunk_chars: LLM分块时每个块建议最大字符数,默认500字符

    Returns:
        List[Document]: 经过LLM分块后的全部Document对象,每个对象代表一个语义分块
    """
    # 1. 读取目录下所有文档,执行文本清洗,得到原始Document列表
    documents = util.clean_all_formats(input_str)

    # 存储最终所有分块后的Document对象
    chunks: List[Document] = []

    # 遍历每一份原始文档,逐个交给LLM做分块
    for d in documents:
        text = d.text

        # 2. 调用LLM语义分块,得到文本字符串列表
        text_chunks = _llm_split_text(text, max_chunk_chars)

        # 3. 将每个文本分块封装成llama_index的Document对象,补充元数据
        for idx, chunk_text in enumerate(text_chunks):
            # 元数据:记录当前分块在这份原始文档内的序号
            meta = {
                "chunk_index": idx
            }
            # 创建Document,绑定分块文本与元数据,加入结果列表
            chunks.append(Document(text=chunk_text, metadata=meta))

    # ==========调试打印:输出所有分块信息,方便查看LLM分块效果==========
    for i, chunk in enumerate(chunks):
        print(f"===== 第{i+1}个分块 =====")
        print(f"元数据:{chunk.metadata}")
        print(f"分块内容:\n{chunk.text}\n")

    return chunks


if __name__ == "__main__":
    # 程序入口:读取config中配置的文档目录路径,执行LLM语义分块
    llm_chunk_documents(config.structure_path)

到这里,基于大模型的智能语义分块全套逻辑就全部实现完成了。整体流程遵循:读取清洗文档→调用LLM智能分块→正则解析分块→重构Document对象→输出结果,完全适配我们整套RAG项目的分块体系。

我们把大模型分块的代码全部实现完成之后,接下来我们来测试一下整体的分块效果。

我们书写入口测试代码,在if __name__ == "__main__" 主函数中,调用我们刚刚封装好的大模型分块方法。

我们传入路径参数 structure_path。为了方便测试,我不在目录下放多个文件,只单独放一个测试文件即可。如果文件数量多、内容多,分块数量就会变多,同时调用大模型的次数也会变多,测试成本更高。所以我们单文件测试,效果最直观。

我们本次使用的原始测试文件,里面一共只有5段标准段落文本,理论上标准、合理的分块结果应该是5块。

我们首次执行代码,大家可以看到,模型直接分出了16个分块,完全没有按照我们预期的段落结构切分,分块效果错乱、不合理。

出现这个问题的核心原因,就是大模型的 temperature 温度参数带来的输出随机性

这个参数是我们在初始化大模型客户端时定义的。我们再次重新执行一遍代码,观察效果。

第二次执行后,分块结果恢复正常,精准分出了5个分块,严格按照文档原生段落结构完成语义分块,完全符合我们的预期要求。

这里给大家重点强调一个大模型的核心特性:大模型无法保证每次生成的结果完全一致

只有类似于"1+1=2"这种绝对客观、唯一标准答案的问题,大模型每次返回结果才会一模一样。但凡涉及到文本理解、语义切分、主观梳理这类场景,大模型的输出天然带有随机性。

刚刚第一次运行分出16块、第二次运行分出5块,就是最直观的体现。这也说明:大模型可以做智能分块,但效果不是绝对稳定,需要人工调参优化

针对这个问题,我们可以针对性调优:修改客户端初始化时的 temperature 参数。

我们可以把 temperature 调低,让模型的输出随机性降低、结果更加收敛、更加固定,避免出现离谱、错乱的分块结果。限制模型的发散性,保证分块逻辑贴合文档原生结构。

这是大模型本身的特性,也是它的优势所在,灵活但存在不确定性,大家一定要理解这一点。

到这里为止,我们关于基于大语言模型的智能分块所有代码编写、功能实现、效果测试、参数调优的内容就全部讲解完毕了。

相关推荐
旧梦952715 分钟前
Java 枚举类详解:从基础语法到高级用法
java·开发语言·python
yivifu15 分钟前
查拼音程序升级版
开发语言·python
小灰灰搞电子19 分钟前
分享自己写的一个通信协议源码,支持C++和C,双包头+不定长
c语言·开发语言·c++
2601_9621741724 分钟前
Spring 核心技术解析【纯干货版】- XI:Spring 数据访问模块 Spring-Oxm 模块精讲
数据库·python·spring
Brilliantwxx32 分钟前
【C++】初入嵌入式C++复习-----经典面试题100道
开发语言·c++·算法·面试·职场和发展
海兰33 分钟前
【应用】基于 Next.js 16 + Python mplfinance的金融K线图与技术指标可视化平台(二)
javascript·python·金融
Freak嵌入式42 分钟前
告别死循环!Pico 外部中断控制 LED,比轮询高效 100 倍
java·开发语言·stm32·单片机·嵌入式硬件
nangonghen42 分钟前
Agent如何传递不同类型的ID给MCP Server
开发语言·python
weixin_398831661 小时前
佛山AI推广GEO哪家做的专业
人工智能·python