Python RAG 实战手册——数据准备

0 阅读27分钟

当把较长文本输入 RAG 系统时,预处理会把文档拆分成更小的 chunks。每个 chunk 会被转换成一个 embedding vector,用来捕捉其语义含义。在检索阶段,系统通过计算这些向量之间的距离来衡量相似度,从而找到相关文本。

当每个 chunk 恰好包含一条信息,并且可以被独立理解时,这种方法效果最好。关键挑战在于准备能够独立成立、无需依赖周围上下文的 chunks。一个稳健的处理流水线会清洗原始文本,并在合适位置进行切分,从而生成有意义的 chunks。

图 4-1 展示了常见预处理技术:

Text preparation
替换缩写并清洗文本。

Metadata collection
存储页码、来源和作者。

Text splitting
应用 character、recursive、semantic 或 agentic chunking。

目标是生成清晰、无歧义、不需要周围上下文也能理解的 chunks。

image.png

图 4-1:一个简化的 RAG indexing pipeline,其中包括潜在的数据处理技术

将有用 metadata 与每个 chunk 一起存储,可以在 retrieval 时启用 filtering,使检索过程更快也更准确。

你可以在本书 GitHub repository 中找到本章所有代码示例。

4.1 添加 Metadata 以启用 Metadata Filtering

Problem

你想在 vector store 中将 metadata 与 text chunks 一起存储,这样 retrieval 就可以应用 metadata filtering。

Solution

从文档中提取已有 metadata,并计算额外字段。对于专门 use cases,还可以选择使用 LLM-generated metadata,也就是从文档内容派生出来的 metadata,来丰富与文档一起存储的 metadata。按照以下步骤执行:

  1. 提取文档中已有的 metadata,例如 author、title、creation date。
  2. 添加计算得到的 metadata fields,例如 file location、size、page count、text length。
  3. 可选:通过 LLM 分析文档文本,生成 content-based metadata。
  4. 将 metadata 与 text chunk 一起保存。

图 4-2 展示了该工作流。

image.png

图 4-2:提取和生成 metadata fields

要跟随示例运行,安装所需 libraries:

pip install PyPDF2 openai pydantic

接下来,导入一个 PDF,并将其 metadata fields 存储在字典中。这些字段的完整性高度依赖创建 PDF 的软件。许多 PDFs 会包含 author、title、subject、creator、creation date 和 modification date。代码如下:

import PyPDF2
import os

file_path = "../datasets/pdf_files/attention_is_all_you_need_paper.pdf"

with open(file_path, "rb") as file:
    reader = PyPDF2.PdfReader(file)
    metadata = reader.metadata

    text = ""
    for page in reader.pages:
        text += page.extract_text()

图 4-3 展示了从示例 PDF 文档中提取出的已有 metadata fields。

image.png

图 4-3:从 PDF 中提取出的已有 metadata fields

接下来,向 metadata dictionary 添加额外数据点,包括 file size、filename、filepath、page count 和 total text length:

metadata_ext = dict(metadata)
metadata_ext["page_count"] = len(reader.pages)
metadata_ext["file_size"] = os.path.getsize(file_path)
metadata_ext["file_name"] = os.path.basename(file_path)
metadata_ext["file_path"] = file_path
metadata_ext["text_length"] = len(text)

对于专门场景,你可以使用 LLMs 通过 content-based fields 丰富 metadata。在本 recipe 加载的示例 PDF 中,已有 Author 字段为空。由于该 PDF 是一篇科学论文,作者姓名应该出现在内容中的某处。

为了确保提取出的信息以正确格式的干净值返回,可以按照图 4-4 中的流程执行。首先定义一个描述预期数据的 Pydantic model,然后提供一个 system instruction,要求模型根据文档文本填充该 schema。最后,将二者一起发送给 LLM。

image.png

图 4-4:使用 LLMs 从文档文本中生成 metadata

现在实现这些步骤。定义一个用于作者联系信息的 Pydantic model,包括 author name、email address 和 company name,并使用 OpenAI Structured Outputs 提取这些字段:

from pydantic import BaseModel
from openai import OpenAI

client = OpenAI()

class AuthorContact(BaseModel):
    name: str
    company: str
    email: list[str]

class Contacts(BaseModel):
    entries: list[AuthorContact]

system_message = """Extract the contact information of all authors."""

response = client.beta.chat.completions.parse(
    model="gpt-5-mini",
    messages=[
        {
            "role": "system",
            "content": system_message,
        },
        {
            "role": "user",
            "content": text,
        },
    ],
    response_format=Contacts,
)

author_contacts = response.choices[0].message.parsed

metadata_ext_llm = dict(metadata_ext)
metadata_ext_llm["author_contacts"] = author_contacts

图 4-5 展示了 enriched 之后的完整 metadata dictionary。结合原生 PDF fields、计算得到的 metadata,以及可选的 LLM-generated fields,这为 retriever 执行 targeted searches 提供了坚实基础,例如按 creation date 或特定 author 过滤。

image.png

图 4-5:扩展后的 metadata dictionary,其中包括生成的 metadata fields

Discussion

Metadata filtering 的工作方式,是在计算 semantic similarity 之前缩小搜索空间。Vector databases 会先应用 metadata constraints,例如 author == "Smith"date >= 2023,然后只在过滤后的子集上执行 semantic search。这可以减少 semantically similar 但 contextually irrelevant 的 false positives。

当你的文档来自明显不同的 buckets,并且用户通常一次只关心其中一个 bucket 时,应使用 metadata filtering。下面是一些例子:

  • 你在同一个 vector index 中存储 HR、engineering 和 finance 文档,例如搜索前先过滤到 department = "engineering"
  • 你索引来自不同年份或季度的内容,例如当用户询问去年情况时,只搜索 year = 2025
  • 你混合 manuals、tickets 和 emails,例如过滤到 source = "manual",以避免拉取 support chatter。
  • 许多文档使用相同词语表达不同含义,例如 “release” 同时出现在 product docs 和 press releases 中。

图 4-6 展示了 metadata 为什么重要:来自相关领域的内容可能语义相似,但上下文无关。例如,关于 antioxidants 的信息既适用于 medicine,也适用于 environmental engineering。Metadata filtering 会在 semantic search 之前分离这些上下文,防止重叠术语造成 false positives。

image.png

图 4-6:两个语义相似的信息集合可能彼此矛盾

TIP

LLM-generated metadata 会增加预处理成本和延迟。对于 1000 个文档的语料库,预计需要数分钟,并产生 1–5 美元 API 成本。应根据你的 use case 中 retrieval precision 的提升来权衡这项成本。

See Also

OpenAI structured outputs guide 描述了如何通过 LLMs 从文本中提取 metadata fields。

Pinecone metadata filtering documentation 解释了如何在 Pinecone vector database 中执行 retrieval 时应用 metadata filtering。

Chroma metadata filtering documentation 提供了在 Chroma 中使用 metadata filters 的示例。

4.2 通过替换缩写和技术术语提升数据质量

Problem

你希望 vector store 中的每个 text chunk 都是 self-explanatory,即可以作为独立信息片段被理解。

Solution

在生成 embeddings 之前,使用 regexes 和 LLMs 扩展缩写并澄清技术术语。这使得 text chunks 作为独立片段存储时也能自解释。

首先,安装 OpenAI SDK:

pip install openai

接下来,加载一个文本文件,并将缩写替换为其完整形式,后面保留括号中的缩写:

import re

abbreviations_dict = {
    "NLP": "Natural Language Processing",
    "RNN": "Recurrent Neural Network",
    "LSTM": "Long Short-Term Memory",
    "GRU": "Gated Recurrent Unit",
    "TF": "Transformer",
    "MHA": "Multi-Head Attention",
    "FFN": "Feed-Forward Network",
}

file_path = "../datasets/text_files/blog_post_transformers.txt"
with open(file_path, "r") as file:
    text = file.read()

# Replace abbreviations in the text
for abbr, full_form in abbreviations_dict.items():
    text = re.sub(rf"\b{abbr}\b", f"{full_form} ({abbr})", text)

最后,使用 LLM 从提取文本中扩展缩写并澄清技术术语:

import os
from openai import OpenAI

file_path = "../datasets/text_files/EMEA_drives_revenue.txt"

with open(file_path, "r") as file:
    text = file.read()

prompt = f"""
    The text below contains a financial report including a lot of
    abbreviations and technical terms from the finance domain.
    Please replace the abbreviations with their full forms and
    provide a brief explanation of the technical terms, so the
    whole text gets easier to read and understandable for everyone.

    Make sure it's easy enough, so that a 10-year-old school kid could
    understand it.

    Often used abbreviations are:
    - EMEA: Europe, Middle East, and Africa
    - BD: Business Development
    - YoY: Year-over-Year
    - APAC: Asia-Pacific

    Text:
    {text}
    """.strip()

client = OpenAI()
chat_completion = client.chat.completions.create(
    messages=[
        {
            "role": "user",
            "content": prompt,
        }
    ],
    model="gpt-5.2",
)

enhanced_text = chat_completion.choices[0].message.content

图 4-7 展示了结果:左侧是从 slide 中提取的原始文本;右侧是预处理后的版本,其中包含所有信息片段的清晰文本描述,无论这些信息来自 diagram 还是 slide text。LLM 会分析 slide 的完整上下文和信息内容,并将其转换为文本形式。

image.png

图 4-7:替换缩写和技术术语后的增强 text chunks

Discussion

当你扩展缩写并澄清技术术语时,每个 text chunk 都可以独立被理解。这很重要,因为 RAG 系统是孤立检索 chunks 的。Embedding model 只能看到 chunk 内部的词。如果这些词含糊或是缩写,模型可用的 semantic signal 就更少。

例如,“NLP” 远不如 “natural language processing” 信息充分。用户通常会使用短查询或混合语言查询,而 embedding model 受益于文本中同时包含缩写和完整形式。

这种方法有效的原因包括:

  • 基于扩展术语构建的 embeddings 比只有 acronyms 的文本包含更丰富的 semantic signals。
  • 像 “NLP models”、“language models” 或 “text understanding” 这样的查询,仍然可以匹配包含扩展短语的 chunks。
  • Retrieved chunks 更容易被 LLM 理解,不需要猜测 acronym 的含义。

当你有以下内容时,可以使用这种方法:

  • 充满 acronyms 的技术文档
  • 包含 shorthand 的金融或法律报告
  • 医疗或临床文本
  • 任何会被原始专家受众之外的人阅读的文档

以下情况不要使用这种方法:

  • 缩写本身就是标准形式,例如 web development 中的 “HTTP”。
  • 对超大集合进行预处理成本过高。
  • 必须以很低延迟实时摄入文档。

图 4-8 展示了该方案解决的三个常见问题:

  • 像 “PO” 这样的缩写在没有上下文时会变得模糊,可能表示 purchase order、post office 或 product owner。
  • 像 “MJ” 这样的引用脱离原上下文后会失去含义。
  • 图像描述如果没有视觉信息,会不完整。

image.png

图 4-8:文档结构方式会影响 RAG 系统性能

表 4-1 展示了更多提升 chunk 清晰度的技术。

表 4-1:提升数据质量的技术

TechniqueInstead ofUse
Adding contextual sentencesIt improved efficiency by 40%.The new indexing algorithm improved search efficiency by 40%.
Avoiding pronouns and implicit referencesIt was implemented last year.The customer feedback system was implemented last year.
Preserving full names of entitiesGoogle launched it in 2019.Google launched the BERT language model in 2019.
Avoiding ambiguous wordsThe system works better now.The system’s response time improved from 500 ms to 200 ms after optimization.

See Also

OpenAI prompt engineering guide 覆盖了如何使用 LLMs 提升文本质量。

4.3 通过为 Text Chunks 创建 Hypothetical Questions 提升搜索准确率

Problem

你希望通过比较用户问题与从 text chunks 派生出的 hypothetical questions,而不是直接比较用户问题和 text chunks,来提升 retriever 质量。

Solution

图 4-9 展示了完整流程,包括 preprocessing 和 indexing steps,以及 RAG app 运行时的 retrieval。

在 indexing 数据之前,你需要执行以下步骤:

  1. 加载数据并将其拆分为 chunks。
  2. 遍历 chunks,并为每个 text chunk 生成至少一个 hypothetical question。
  3. 为每个 hypothetical question 生成 embeddings。
  4. 将 hypothetical questions 及其 embeddings 存储到数据库中。

image.png

图 4-9:使用 LLMs 生成 hypothetical questions

每个 hypothetical question 都会链接回其原始 text chunk。在 RAG app 运行时,retriever 会搜索 hypothetical questions,但你通常会将底层 text chunks 传给 LLM,因此模型收到的是完整上下文,而不只是派生出的 hypothetical questions。

要执行代码,首先安装 OpenAI SDK 和 Pydantic:

pip install openai pydantic

接下来,加载两个学生讨论 AI 在制造业中应用的 chat history。LLM 会分析文本,并生成五个 hypothetical questions。每个问题都可以用文本中包含的信息回答。代码如下:

import textwrap
from openai import OpenAI
from pydantic import BaseModel

file_path = "../datasets/text_files/AI_in_factories_chat.txt"

with open(file_path, "r", encoding="utf-8") as file:
    text = file.read()

client = OpenAI()

prompt = textwrap.dedent(
    f"""
    Below you can find a chat history between two students.

    Please generate 5 hypothetical questions that could be
    answered using the information from the discussion.
    The questions should focus on key details, definitions, and
    information present in the text.

    Chat History:
    {text}
    """
)

class HypotheticalQuestions(BaseModel):
    questions: list[str]

result = client.beta.chat.completions.parse(
    model="gpt-5-mini",
    messages=[{"role": "user", "content": prompt}],
    response_format=HypotheticalQuestions,
)

hypothetical_questions = result.choices[0].message.parsed.questions
hypothetical_questions

图 4-10 展示了生成输出。

image.png

图 4-10:LLM 生成的 hypothetical questions

Discussion

Hypothetical questions 可以减少用户表达 query 的方式与文档写作方式之间的差距。你不是 embedding 原始 chunks,例如 code、logs 或 tables,而是 embedding 描述这些 chunks 内容的问题。这使 retrieval 的行为更接近用户真实搜索方式。

这种方法有效的原因包括:

  • Query embeddings 更接近 question-style text,而不是 raw technical content。
  • Generated questions 用自然语言表达了一个 chunk 背后的意图。
  • 它把 code 和 tables 这类难以 embedding 的内容拉入与用户 query 相同的 semantic space。

在以下场景使用 hypothetical questions:

  • 文档包含 code、configuration files 或 database schemas。
  • 内容包括 logs、transcripts 或 chat histories。
  • 数据以 tables 或其他 structured formats 存储。
  • 用户通常会问自然语言问题,例如 “How do I…” 或 “What is…”。

以下情况不要使用 hypothetical questions:

  • 文档本身已经以 questions and answers 形式写成,例如 FAQs。
  • 你无法承担 ingestion 阶段额外的 LLM calls。
  • 文本已经是清晰的叙事 prose。
  • Ingestion 必须实时发生,并且 latency 很重要。

图 4-11 展示了 semantic alignment 问题:code blocks、chat logs 和 tables 往往在 embedding space 中离自然语言用户 queries 很远,即使底层内容相关。Hypothetical questions 会把这些内容重新定位到更接近典型用户提问模式的位置。

image.png

图 4-11:Hypothetical questions 在 embedding space 中更接近潜在用户问题的位置

在 retrieval 时,系统会将用户 query 与已存储的 hypothetical questions 比较,而不是与 raw chunks 比较。当某个 hypothetical question 匹配时,retriever 会获取与该问题链接的原始 text chunk。这会为 LLM 提供完整上下文,而不只是派生问题。

NOTE

这个 recipe 通过从 text chunks 生成 hypothetical questions,在 indexing 阶段解决 semantic alignment 问题。另一种在 query time 处理 alignment 的方法,请参考第 7 章中的 hypothetical document embeddings(HyDE)recipe。HyDE 会从用户 query 生成 hypothetical documents,而不是预处理你的数据。你可以根据 retrieval quality 需求和性能要求,使用其中一种或同时使用两种技术。

See Also

以下资源覆盖 HyDE。HyDE 已经成为处理 retrieval 中 semantic alignment 的更流行方法。HyDE 在运行时从 queries 生成 hypothetical documents,而这个 recipe 采取相反方法,即在 indexing 阶段从 text chunks 生成 hypothetical questions。HyDE 实现更广泛采用,因为它不需要预处理所有文档;不过两种技术都可能有效,取决于你的 use case。更多细节见以下资源:

Haystack HyDE implementation 展示了如何通过 Haystack framework 实现 HyDE。

LangChain HyDE retriever API reference 解释了如何在 LangChain 中使用 HyDE-style retrieval。

LlamaIndex query transformations documentation 覆盖 query-transformation approaches,包括 HyDE-style techniques。

4.4 通过 Character Splitting 拆分文档

Problem

你需要把长文档拆分为较小 sections,这些 sections 要足够小,能够使用现有 embedding models 创建 embeddings。

Solution

Character-based splitting 是最简单的文本拆分方式。它使用固定大小窗口在文本上滑动,并切出 snippets。图 4-12 通过示例展示了这一点。在该例中,chunk 2 和 chunk 3 存在 overlap。

image.png

图 4-12:使用固定 character 或 token size 拆分文本

在这个示例中,你使用 100 characters 的 chunk length:

file_path = "../datasets/text_files/blog_post_transformers.txt"

with open(file_path, "r") as file:
    text = file.read()

def split_by_characters(text, chunk_size, overlap):
    chunks = []
    step = max(1, chunk_size - overlap)

    for start in range(0, len(text), step):
        end = start + chunk_size
        chunk = text[start:end]
        if chunk:
            chunks.append(chunk)

    return chunks

chunks = split_by_characters(text, chunk_size=100, overlap=20)

Discussion

Character splitting 的工作方式是在文本上移动一个固定大小窗口,并提取 substrings。这种技术忽略所有文档结构,将文本当作连续字符流。它是最简单的 chunking 方法,但也是最不复杂的方法。它只计算字符并拆分,经常会撕裂句子和段落。图 4-12 清楚展示了这一点:chunk boundaries 是任意落下的,可能切断词语或在句子中间打断思想。

当你有以下内容时,可以使用 character splitting:

  • 没有自然结构的原始音频或视频转录文本
  • 没有 delimiters 的拼接 logfiles 或 data dumps
  • 在实现更复杂 chunking 前快速 prototyping

以下情况不要使用 character splitting:

  • 任何带 paragraph breaks、headings 或其他 structure markers 的文档
  • 中途切断句子会损害含义的内容,也就是大多数文档

NOTE

Overlap considerations:设置 overlap,例如 20 characters,意味着相邻 chunks 会在边界处共享文本。这有助于保留跨 chunk 边缘的上下文,但会增加存储需求和冗余。

表 4-2 总结了什么时候使用 character splitting,以及什么时候使用替代方案。

表 4-2:何时使用 character splitting 与替代方案

ScenarioRecommended approach
原始音频转录等没有自然边界的非结构化文本使用 character splitting。
结构化文档,例如 blog posts、reports使用 recursive chunking(Recipe 4.5)或 document-aware splitting(Recipe 4.6),以保留 paragraphs 和 sections。
Markup documents,例如 Markdown、LaTeX使用 document-aware splitting,将语法作为 splitting cues。
复杂非结构化文本使用 semantic chunking(Recipe 4.7)或 agentic chunking(Recipe 4.8),寻找基于意义的 breakpoints。

See Also

LangChain character text splitter documentation。

4.5 使用 Recursive Text Splitters 拆分文档

Problem

你想拆分具有清晰结构的文档,包括 headings、chapters 和 paragraphs,并在拆分时保留这种结构。

Solution

Recursive text splitter 会识别人类通常用于组织文档的字符,例如 newlines、spaces 和 punctuation marks。它会在自然边界处拆分文本,优先使用更大的结构性断点,例如 newlines;当达到 chunk size limits 时,再回退到 punctuation marks。

图 4-13 展示了一个示例文本会如何被 chunk。第一次拆分自然发生在 newline;第二次拆分,也就是 chunk 2 和 chunk 3 之间,则是在达到一定长度后发生在 punctuation mark。

image.png

图 4-13:使用 recursive chunking 拆分文本

这个 recipe 使用 LangChain text splitters 进行演示。

TIP

你也可以使用 regexes 自己定义 recursive text splitter,用来识别 newlines 和 punctuation marks 等 separators。这样可以避免给项目增加 orchestration frameworks 依赖,使实现更轻量。

安装所需 packages:

pip install PyPDF2 langchain-text-splitters

然后加载并 chunk 一个 newsletter page。Splitter 会计算 characters 或 tokens,当达到定义的最大 size 时,会寻找下一个 separator 来拆分文本:

from langchain_text_splitters import RecursiveCharacterTextSplitter
import PyPDF2

file_path = "../datasets/pdf_files/daily_insights.pdf"

with open(file_path, "rb") as file:
    reader = PyPDF2.PdfReader(file)

    text = ""
    for page in reader.pages:
        text += page.extract_text()

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=200,
    chunk_overlap=0,
    length_function=len,
    is_separator_regex=False,
)

chunks = text_splitter.split_text(text)

图 4-14 展示了生成的 chunks 列表和每个 chunk 的 character length。每个 chunk 最大长度为 200 characters。

image.png

图 4-14:Recursive text splitting

NOTE

默认 separator 列表适用于大多数文本和语言。不过,对于中文、日文和泰文等缺少词边界的语言,这些 separators 可能会错误切分词语。为了避免这种情况,可以自定义 separator 列表。最佳实践可以在 LangChain 文档中找到。

Discussion

Recursive chunking 会按优先级识别自然文本边界:先是 paragraph breaks,然后是 single newlines,再是 punctuation,最后是 spaces。当一个 chunk 达到 size limit 时,splitter 会使用可用的最高优先级 separator。

这种方法有效的原因包括:

  • 通过尊重人类组织文本的方式,保留 semantic coherence。
  • 将相关句子保留在同一个 chunk 中。
  • 减少破坏含义的 mid-sentence splits。

以下场景适合使用 recursive chunking:

  • 具有清晰 paragraph structure 的文档,例如 articles、reports、books。
  • 处理混合文档类型的 general-purpose RAG systems。
  • 当文档格式未知时,作为默认选择。

以下情况不要使用 recursive chunking:

  • Markup documents,例如 Markdown、LaTeX、HTML,因为 document-aware splitting(Recipe 4.6)能更好保留 heading structure。
  • Domain-specific content,其中 semantic boundaries 比 structural boundaries 更重要,需要 semantic chunking(Recipe 4.7)。

图 4-15 展示了 fixed-size chunking 的风险:当你每固定数量 tokens 就拆分文本,例如每 1000 tokens,一不小心就会将文档中不同 sections 的内容组合到同一个 chunk 中。例如在报纸中,同一页上的 politics、sports 和 healthcare articles 可能最终落在同一个 chunk 中,混合无关主题。Recursive chunking 通过尊重 paragraph 和 section boundaries 降低这种风险,让相关信息保持在一起。

image.png

图 4-15:每个 text chunk 应尽可能清楚地表示一个特定信息片段

你可以添加 overlaps,使一个 chunk 的最后一句也出现在下一个 chunk 的开头。这有助于 embedding model 捕捉跨多个 chunks 的上下文。不过,overlaps 会增加存储成本,并可能造成重复检索。

TIP

Recursive character chunking 可以用 regexes 从零直接实现,例如搜索 newlines 和 punctuation marks 等 separators。对于生产使用,可以考虑自己实现一个简单版本,以避免给项目增加 orchestration framework dependencies。

See Also

LangChain recursive text splitter documentation 解释了 recursive splitting 如何工作,以及如何配置 separators、chunk size 和 overlap。

4.6 使用 Document-Aware Splitting 对文档分块

Problem

你需要加载 markup documents,并希望使用文档特定语法将它们拆分为 chunks。

Solution

Markdown 等 markup formats 使用特定语法。例如,headings 以 hash sign(#)开头,bullet lists 常用 asterisk(*),bold text 使用两个 asterisks(**)包裹。Document-aware splitters 理解这种语法,并用它来 chunk 文本。

图 4-16 展示了不同文件格式的示例 code snippets。

image.png

图 4-16:HTML、Python、Markdown 和 LaTeX 的示例代码

这个 recipe 使用 LangChain Text Splitters,以避免为每种文件格式定义语法和 splitting rules。安装 package:

pip install langchain-text-splitters

这个示例使用三种 text splitters:一个用于 Python code,一个用于 LaTeX code,一个用于 Markdown files。代码会检查 file extension 并选择合适 splitter。在这个示例中,它加载一个 .md 扩展名的 Markdown 文件:

import os
from langchain_text_splitters import (
    PythonCodeTextSplitter,
    LatexTextSplitter,
    MarkdownHeaderTextSplitter,
)

file_path = "../datasets/markdown_files/random_md_code.md"
file_extension = os.path.splitext(file_path)[1]

with open(file_path, "r") as file:
    file_text = file.read()

if file_extension == ".py":
    splitter = PythonCodeTextSplitter(chunk_size=500, chunk_overlap=50)
elif file_extension == ".tex":
    splitter = LatexTextSplitter(chunk_size=500, chunk_overlap=50)
elif file_extension == ".md":
    headers_to_split_on = [
        ("#", "Header 1"),
        ("##", "Header 2"),
        ("###", "Header 3"),
    ]

    splitter = MarkdownHeaderTextSplitter(headers_to_split_on)

chunks = splitter.split_text(file_text)

输出是一个 Python objects 列表。每个 object 表示一个 text chunk,并包含 metadata,例如 header_1header_2header_3,以及 page content。换句话说,每个 chunk 对应一个文档 section,同时保留 heading levels 1、2、3 等结构化 metadata。

图 4-17 展示了示例文档生成的 text chunks。

image.png

图 4-17:Markdown 文件的 Document-aware splitting

Discussion

Document-aware splitting 利用格式特定语法来识别逻辑边界。Markdown splitters 识别 heading markers,例如 ######;Python splitters 识别 function 和 class definitions;HTML splitters 尊重 tag structures;LaTeX splitters 理解 section commands。

Document-aware splitting 有效的原因包括:

  • 保留作者写作时意图表达的逻辑结构。
  • 保持 code functions、sections 或 paragraphs 的完整性。
  • 将 hierarchical information,例如 heading levels,捕捉为 metadata。
  • 对结构化格式而言,比通用 text splitting 更准确。

以下场景适合使用 document-aware splitting:

  • Source code repositories,例如 Python、JavaScript、Java 等。
  • Markdown documentation 和 READMEs。
  • HTML pages 或 web-scraped content。
  • LaTeX papers 和 academic documents。
  • 任何用具有正式语法的 markup language 写成的内容。

以下情况不要使用 document-aware splitting:

  • 没有 markup 的 plain text,应使用 recursive chunking。
  • 被提取为 plain text 的 PDF 或 Word documents,因为没有可利用的语法。
  • 需要多个 splitters 的 mixed-format documents。
  • Chat logs 或 transcripts 等非结构化格式。

Document-aware splitting 与 recursive chunking 的区别:

  • Recursive chunking 会寻找通用于所有文本的 generic separators,例如 newlines、punctuation。
  • Document-aware splitting 理解 format-specific structure,例如 HTML tags、Python function definitions、Markdown headers。
  • Document-aware splitting 会保留 hierarchical metadata,例如一个 chunk 属于哪个 heading。

利用已有文档结构的 splitter,是最流行、最高效的文档 chunk 方法之一。这个方法速度快,并且通过使用 headings、paragraphs 和 punctuation,可以让属于同一语义单元的内容保持在同一个 chunk 中。

你可以使用 regexes 搜索文本并构建自己的 splitting rules,也可以使用现成 text-splitting library,例如 LangChain Text Splitters,它已经包含 Markdown、Python、HTML 和 LaTeX 等格式的规则。

要使用特定文件类型语法,splitter 必须理解该格式的 syntax elements。本 recipe 使用 LangChain Text Splitters,是因为它免去了将每个 syntax element 编码成专用 regex 的工作。对于生产解决方案,如果你希望最小化 dependencies,可以考虑避免 LangChain,并用 regexes 自己实现 document-aware splitter。

关键取舍是:document-aware splitting 需要 format detection 和 format-specific parsers。这会增加复杂度,但相比通用方法,它可以为结构化内容提供更高质量 chunks。

See Also

LangChain Text Splitters API reference 提供了可用 splitters 及其配置选项概览。

LlamaIndex node parsers documentation 解释了 LlamaIndex 如何将 documents 解析和结构化为用于 retrieval 的 nodes。

4.7 使用 Semantic-Aware Chunkers 拆分文本

Problem

你想使用语义主题边界,而不是任意字符数量,来 chunk 非结构化文档,例如 audio transcriptions、chat logs 或 concatenated text。

Solution

图 4-18 展示了 semantic chunking pipeline:

  1. 将文本拆分成小片段,例如单独句子。
  2. 使用 embedding model 为每个小文本片段生成 embeddings。
  3. 通过计算连续片段 embedding vectors 之间的距离,衡量它们的 semantic similarity。
  4. 如果两个连续片段非常相似,就将它们合并成更大的 chunk。持续添加句子,直到与下一个片段的相似度低于某个阈值。

image.png

图 4-18:使用 semantic similarity 定义 breakpoint

这个 recipe 使用 OpenAI text embedding models。为了保持 recipe 简短,我们不从零实现 semantic chunking,而是使用 LangChain Experimental package 中的 SemanticChunker。按如下方式安装:

pip install langchain-experimental langchain-openai

接下来,加载一段没有任何结构的文本样本,也就是没有 headings、没有 paragraphs、没有 bullet points。在这个示例中,splitting threshold 设置为第 90 百分位:

from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai.embeddings import OpenAIEmbeddings

file_path = (
    "../datasets/text_files/"
    "random-text-about-5-different-stories.txt"
)

with open(file_path, "r") as file:
    text = file.read()

text_splitter = SemanticChunker(
    OpenAIEmbeddings(),
    breakpoint_threshold_type="percentile",
    breakpoint_threshold_amount=90,
)

chunks = text_splitter.split_text(text)

图 4-19 展示了使用 semantic chunker 后,从示例文档中拆分出的 chunks。

image.png

图 4-19:使用 semantic chunker 后拆分出的 text chunks

Discussion

Semantic chunking 会分析内容含义来识别自然 breakpoints,而不是依赖文档结构或固定字符数量。这种方法会为小文本片段,通常是句子,生成 embedding vectors,然后测量连续片段之间的 similarity。当相似度低于某个阈值时,系统会创建新的 chunk。

本 recipe 使用 percentile 方法设置 breakpoints。系统会计算所有连续句子对之间的距离,然后把阈值设置为这些距离的第 90 百分位。只有当 similarity drop 大于文档中所有 observed drops 的 90% 时,系统才会创建一个新 chunk。

这种方法有效的原因包括:

  • 能识别不与结构边界对齐的 topic shifts。
  • 即使格式不一致,也能将语义相关内容分组。
  • 基于 meaning 而不是任意规则创建 chunks。

以下场景适合使用 semantic chunking:

  • 没有清晰格式的非结构化文本,例如 transcripts、concatenated emails、chat logs。
  • Topic boundaries 与 paragraph breaks 不匹配的文档。
  • Retrieval quality 足以 justify 更高预处理成本的 use cases。

以下情况不要使用 semantic chunking:

  • 文档有清晰结构,document-aware 或 recursive splitting 已经效果很好。
  • 大规模语料库会导致 embedding costs 过高。
  • Real-time ingestion pipelines 中 embedding latency 会造成瓶颈。

NOTE

Semantic chunking 需要对每个文本片段 embedding 两次:一次在 chunking 过程中用于识别边界,另一次在将 chunks 摄入 vector store 时使用。按照典型 embedding model pricing,每 1M tokens 0.02 美元,这会增加可衡量成本。应根据 use case 中 retrieval quality 的提升来权衡这项成本。

TIP

对于生产系统,可以参考 LangChain source code 自己实现,而不是依赖 Experimental package。这样可以更好控制实现,并避免 experimental APIs 可能出现的 breaking changes。

See Also

Greg Kamradt 的 “5 Levels of Text Splitting” tutorial 提供了逐步更高级 splitting strategies 的实践示例。

LlamaIndex semantic chunker examples 展示了如何使用 LlamaIndex SemanticChunker 实现 semantic chunking workflows。

LangChain SemanticChunker source code 展示了 LangChain SemanticChunker 的实现细节。

4.8 使用 Agentic Chunkers 拆分文本

Problem

你正在处理长而复杂的文档,并希望将相关信息打包成有意义的 text chunks。

Solution

Agentic chunking 会创建名为 propositions 的 standalone statements,每个 proposition 描述一个清晰信息片段。Agentic chunking 使用 LLM 分析文本,并将其拆解为这些 propositions。

图 4-20 展示了 agentic chunking workflow:

  1. 将文本拆分成 chunks。
  2. 遍历每个 chunk,将其发送给 LLM,并生成 propositions。
  3. 遍历所有 propositions 并彼此比较。如果它们包含冗余信息,则合并。

image.png

图 4-20:Agentic chunkers 的工作方式

本 recipe 中使用的 prompt template 来自最早描述 agentic chunking 概念的论文之一。你可以在 LangChain Hub 中找到完整 prompt。下面是一个缩短版本,用于帮助你理解它大概是什么样子:

Decompose the "Content" into clear and simple propositions,
ensuring they are interpretable out of context.

1. Split compound sentence into simple sentences.
    Maintain the original phrasing from the input whenever possible.
2. For any named entity that is accompanied by additional
    descriptive information, separate this information into its
    own distinct proposition.
3. Decontextualize the proposition by adding necessary modifier to
    nouns or entire sentences and replacing pronouns (e.g., "it", "he"
    "she", "they", "this", "that") with the full name of the entities
    they refer to.
4. Present the results as a list of strings, formatted in JSON.

Example:

Input: Title: Eostre. Section: Theories and interpretations,
    Connection to Easter Hares. Content:
The earliest evidence for the Easter Hare (Osterhase) [...]
    America where it evolved into the
Easter Bunny.
Output: [ "The earliest evidence for the Easter Hare was recorded
    in south-west Germany in 1678 by Georg Franck von
    Franckenau.", "Georg Franck von Franckenau ..", "..."]

你将 prompt 和待处理 text chunk 一起发送给 LLM。唯一需要的 packages 是 OpenAI SDK 和 Pydantic,可以按如下方式安装:

pip install langchain langchain-openai pydantic

然后使用这个 prompt 创建 propositions:

from langchain import hub
from langchain_openai import ChatOpenAI
from pydantic import BaseModel
from typing import List

# Pull the prompt template from the langchain hub
obj = hub.pull("wfh/proposal-indexing")

llm = ChatOpenAI(model="gpt-5.2")

class Sentences(BaseModel):
    sentences: List[str]

extraction_llm = llm.with_structured_output(Sentences)

# Create the sentence extraction chain
extraction_chain = obj | extraction_llm

propositions = extraction_chain.invoke(
    """
    On July 20, 1969, astronaut Neil Armstrong walked on the moon .
    He was leading the NASA's Apollo 11 mission.
    Armstrong famously said, "That's one small step for man, one
    giant leap for mankind" as he stepped onto the lunar surface.
    """
)

图 4-21 展示了 LLM 从前面代码中的文本片段推导出的 propositions。根据关于 Apollo 11 mission 的短文本样本,模型生成了四个 standalone statements。

image.png

图 4-21:生成的 propositions

现在,你可以直接使用这些 statements,或者先合并相关 propositions。

Discussion

Agentic chunking 使用 LLMs 将文本拆解为 standalone propositions,也就是原子陈述。每个 proposition 都传达一个清晰的信息片段,并且不依赖周围上下文。这会生成最高质量的 retrieval chunks,但也需要最多预处理工作。

这种方法有效的原因包括:

  • 消除 pronouns 和 implicit references 带来的歧义。
  • 创建真正 self-contained 的 statements,可以被独立理解。
  • 在预处理阶段解决文档内部的 cross-references。
  • 将冗余信息合并成单一、全面的 statements。

以下场景适合使用 agentic chunking:

  • 包含大量 clauses 之间 cross-references 的法律合同。
  • 引用其他 sections 或 external standards 的技术规范。
  • 包含 pronouns 和 implicit references,导致 chunks 含糊的文档。
  • Retrieval accuracy 足以 justify 高预处理成本的 use cases。

以下 use cases 不适合使用 agentic chunking:

  • 段落清晰、自包含的高质量文档。
  • LLM 成本会过高的大型文档语料库。
  • Real-time ingestion pipelines,因为 LLM latency 会让它不现实。

Agentic chunking 与替代方案不同:

  • 所有其他 chunking 方法都是在已有文本边界处拆分。
  • Agentic chunking 会将文本转换为新的 standalone statements。
  • Semantic chunking 按主题分组;agentic chunking 会 decontextualize 并 atomize。
  • 这是唯一会主动解决 references 和 pronouns 的方法。

下面的例子展示了转换过程。原始 text chunk 是:“Sarah bought a new book. She enjoys reading fantasy novels.”

LLM 从这两句话中推导出 standalone propositions:

  • Sarah bought a new book.
  • Sarah enjoys reading fantasy novels.

注意,第二个 proposition 中的 “She” 被替换为 “Sarah”,使其 self-contained。

要合并相似 propositions,按照以下步骤执行:

  1. 为所有 propositions 生成 embeddings。
  2. 计算 proposition pairs 之间的 semantic similarity。
  3. 将高度相似 propositions 合并成一个 statement。
  4. 保留不同 propositions。

实现时,可以使用简单 similarity threshold,例如 cosine similarity > 0.9,来识别候选合并项,然后将它们发送给 LLM,生成最终合并 statement。

Contracts 是典型 use case。一份 100 页合同中,clauses 可能引用其他 sections 或 external documents,这会给基础 RAG 系统带来挑战。将这些内容转换为不依赖周围上下文的 standalone statements,可以显著提升 retrieval accuracy。

NOTE

Agentic chunking 每个 chunk 需要两到三次 LLM calls,用于 generation 和可选 merging。对于一个 500-chunk 的文档,如果使用 GPT-4 class models,预计需要 30–60 分钟和 15–40 美元 API 成本。这比 semantic chunking 贵 10 到 20 倍。只有在 retrieval quality 极其重要时才使用。

See Also

Greg Kamradt 的 “5 Levels of Text Splitting” tutorial 提供了逐步更高级 splitting strategies 的实践示例。

Thuwarakesh Murallie 的 agentic chunking implementation guide 解释了如何在实践中实现 agentic chunking。