战术篇(上) - 精雕细琢:驾驭结构化与半结构化知识
5️⃣

战术篇(上) - 精雕细琢:驾驭结构化与半结构化知识

至此,我们已经规划了宏伟的蓝图(战略篇),设计了智能的大脑(架构篇),并组建了专业的团队(组织篇)。现在,是时候深入一线,为我们的"专家小队"配备最精良的武器了。欢迎来到战术篇!
本篇是技术深潜的第一站,我们将聚焦于企业知识库中最常见、也最容易产生价值的一类数据:结构化与半结构化知识。这包括技术手册、API文档、网页内容、Markdown格式的内部Wiki、甚至是格式清晰的Word文档。
这类文档的共同特点是:作者已经通过标题、列表、代码块等形式,为内容赋予了清晰的逻辑结构。 我们的核心战术,就是最大化地利用这些结构信息,实现最高效、最精确的切分与检索。
本篇我们将深入讲解并实战演练三种核心武器:
  1. 结构化切片 (Markdown/HTML):精准拆解的"解剖刀"。
  1. 递归切片 (Recursive Chunking):灵活通用的"瑞士军刀"。
  1. 父文档检索器 (Parent Document Retriever):兼顾全局与细节的"广角+微距"镜头。

准备工作:环境设置

在开始实战之前,请确保你已经安装了必要的Python库。我们将主要使用 langchain 生态来实现这些策略。
# 安装核心库 pip install -q langchain langchain_community langchain_openai # 安装用于向量化和存储的库 pip install -q faiss-cpu tiktoken # 如果要处理HTML,需要安装这个库 pip install -q beautifulsoup4
为了运行代码,你还需要设置你的OpenAI API密钥。
import os from langchain_openai import OpenAIEmbeddings # 建议使用环境变量来管理你的API密钥 # os.environ["OPENAI_API_KEY"] = "sk-..." # 初始化嵌入模型 # 我们将使用OpenAI的text-embedding-3-small模型,它在性能和成本上取得了很好的平衡 embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

武器一:结构化切片 (Structural Chunking)

这是处理具有明确层级结构(如Markdown、HTML)文档的首选武器。它的核心思想是:让机器像人一样,通过看标题来理解文档结构

1. Markdown标题切片 (MarkdownHeaderTextSplitter)

MarkdownHeaderTextSplitter 可以根据Markdown文件中的# ## ###等标题层级来进行分割,并将标题本身作为元数据附加到每个块上。
代码实战:
from langchain.text_splitter import MarkdownHeaderTextSplitter # 假设我们有一个Markdown格式的技术手册 markdown_text = """ # LangChain 简介 LangChain是一个强大的框架,旨在简化利用大型语言模型(LLM)的应用开发。 ## 核心组件 LangChain包含几个核心部分。 ### 1. 模型 I/O (Models I/O) 这部分负责与语言模型进行交互。 ### 2. 检索 (Retrieval) 检索模块用于从外部数据源获取信息。 ## 快速入门 让我们看一个简单的例子。 """ # 定义我们关心的标题层级 headers_to_split_on = [ ("#", "Header 1"), ("##", "Header 2"), ("###", "Header 3"), ] # 初始化切片器 markdown_splitter = MarkdownHeaderTextSplitter( headers_to_split_on=headers_to_split_on, strip_headers=True # 选项:是否从内容中移除标题本身 ) # 执行切分 md_header_splits = markdown_splitter.split_text(markdown_text) # 让我们看看结果 for i, split in enumerate(md_header_splits): print(f"--- 块 {i+1} ---") print(f"内容: {split.page_content}") print(f"元数据: {split.metadata}\\n")
notion image
 
结果分析与召回策略:
上面的代码会将Markdown文本精确地切分为以下几个块:
  • 块1:
    • 内容: LangChain是一个强大的框架,旨在简化利用大型语言模型(LLM)的应用开发。
    • 元数据: {'Header 1': 'LangChain 简介'}
  • 块2:
    • 内容: LangChain包含几个核心部分。
    • 元数据: {'Header 1': 'LangChain 简介', 'Header 2': '核心组件'}
  • 块3:
    • 内容: 这部分负责与语言模型进行交互。
    • 元数据: {'Header 1': 'LangChain 简介', 'Header 2': '核心组件', 'Header 3': '1. 模型 I/O (Models I/O)'}
  • 块4:
    • 内容: 检索模块用于从外部数据源获取信息。
    • 元数据: {'Header 1': 'LangChain 简介', 'Header 2': '核心组件', 'Header 3': '2. 检索 (Retrieval)'}
  • 块5:
    • 内容: 让我们看一个简单的例子。
    • 元数据: {'Header 1': 'LangChain 简介', 'Header 2': '快速入门'}
召回工作流:元数据过滤的力量
现在,假设一个用户问:"LangChain的检索组件是做什么的?"
一个先进的RAG系统会执行如下的混合搜索 (Hybrid Search) 流程:
  1. 用户查询分析:系统可能会识别出查询中的关键词"检索组件"。
  1. 执行混合搜索:系统向向量数据库发起一个包含语义查询元数据过滤的请求。
    1. # 伪代码示意 retriever.search( query="检索组件是做什么的?", metadata_filter={ "Header 3": {"contains": "检索"} } )
  1. 第一步:元数据预过滤 (Pre-filtering)。向量数据库首先不过进行任何昂贵的向量计算,而是闪电般地执行元数据过滤。它会扫描所有文本块的元数据,只筛选出那些 Header 3 字段包含 "检索" 一词的块。在我们的例子中,数百万个块可能瞬间就被过滤得只剩下 块4
  1. 第二步:在子集上进行语义搜索 (Semantic Search on Subset)。现在,系统只需要在块4这个极小的集合上进行语义相似度计算。这几乎没有计算成本。
  1. 返回精准结果:系统最终精确地返回 块4 的内容:"检索模块用于从外部数据源获取信息。",并将其交给LLM生成最终答案。
核心优势效率与精度的双重提升。这种"先过滤,后搜索"的模式,将结构化数据库查询的"确定性"和向量搜索的"模糊语义匹配能力"完美结合。它避免了在整个知识库中进行大海捞针式的语义搜索,极大地缩小了搜索范围,防止了其他章节中可能出现的、但相关性不高的"检索"一词的干扰,最终实现了更快、更准的召回。

2. HTML标题切片 (HTMLHeaderTextSplitter)

与Markdown类似,我们可以用 HTMLHeaderTextSplitter 来处理网页内容,利用<h1> <h2>等标签进行切分。这对于构建基于公司官网或在线知识库的RAG系统非常有用。代码实现与Markdown版本高度相似,只需将MarkdownHeaderTextSplitter换成HTMLHeaderTextSplitter即可。

武器二:递归切片 - 半结构化知识的"瑞士军刀"

现在,我们把目光投向企业知识库中占比最大的内容:半结构化文档。并非所有文档都有完美的标题结构。对于那些以段落为主要结构,但格式不一的文档(如博客文章、新闻稿、大多数内部Wiki页面),递归切片 (Recursive Character Text Splitter) 就是我们最可靠的通用武器。
它的工作原理完美地契合了半结构化文档的特点:试图用一个分隔符列表(按优先级排序)来分割文本。它会先用最高优先级的\\n\\n(段落)尝试分割——这正是半结构化文档最自然的边界。如果切分后的块仍然太大,它才会"退而求其次",在那个大块内部用次一级的分隔符\\n(换行)来分割,以此类推。
代码实战:
from langchain.text_splitter import RecursiveCharacterTextSplitter # 一段典型的半结构化博客文章 blog_text = """ 今天我们来聊聊RAG系统中的一个关键参数:chunk_size。 chunk_size决定了每个文本块的大小。太大的chunk_size会包含过多无关信息,稀释语义,导致检索不精确。 另一方面,太小的chunk_size可能破坏语义完整性。比如,一个完整的论点被分割到两个不同的块中,LLM就很难理解了。 那么,最佳实践是什么呢? 一个常见的起点是512或1024个token。但这并非绝对,你需要根据你的文档特性和LLM的上下文窗口大小进行实验。 关键在于平衡。 """ # 初始化递归切片器 # LangChain的默认分隔符是 ["\\n\\n", "\\n", " ", ""],这通常是个很好的起点 text_splitter = RecursiveCharacterTextSplitter( chunk_size=120, # 每个块的目标大小(这里用字符数是为了演示方便) chunk_overlap=20, # 块之间的重叠 length_function=len, # 使用len函数来计算长度 ) chunks = text_splitter.split_text(blog_text) # 看看切分结果 for i, chunk in enumerate(chunks): print(f"--- 块 {i+1} (长度: {len(chunk)}) ---") print(chunk) print()
notion image
结果分析与召回策略:
  • 切分结果:上面的代码会优先在\\n\\n(段落)处分割,同时确保每个块不超过120个字符。由于chunk_overlap=20,相邻的块会有20个字符的重叠。
    • 块1: 今天我们来聊聊RAG系统中的一个关键参数:chunk_size。\\nchunk_size决定了每个文本块的大小。太大的chunk_size会包含过多无关信息,稀释语义,导致检索不精确。
    • 块2: 稀释语义,导致检索不精确。\\n\\n另一方面,太小的chunk_size可能破坏语义完整性。比如,一个完整的论点被分割到两个不同的块中,LLM就很难理解了。
    • 块3: 一个完整的论点被分割到两个不同的块中,LLM就很难理解了。\\n\\n那么,最佳实践是什么呢?\\n一个常见的起点是512或1024个token。
    • 块4: 个常见的起点是512或1024个token。但这并非绝对,你需要根据你的文档特性和LLM的上下文窗口大小进行实验。\\n关键在于平衡。
  • 召回工作流:依靠语义和重叠
    • 假设用户提问:"chunk_size的最佳实践是什么,为什么说它需要平衡?"
      递归切片的召回依赖于标准的语义搜索,并巧妙地利用了**块重叠(chunk overlap)**的优势:
      1. 语义匹配:用户的查询向量在语义上会同时接近 块3(提到了"最佳实践")和 块4(解释了"需要实验"和"平衡")。
      1. 检索Top-K个块:典型的RAG系统会召回最相关的Top-K个块(比如K=2或3)。在这种情况下,系统很可能会同时召回 块3块4
      1. 重叠的价值:即使一个关键句子被切分开,chunk_overlap也能确保这个句子的上下文信息被两个块共享,这进一步增加了相关块被同时召回的概率。
      1. 提供完整上下文:最终,LLM会得到一组内容互补的文本块,它能从中看到"最佳实践是512-1024个token",也能看到"但这并非绝对,需要根据情况平衡",从而给出一个全面而准确的回答。
  • 最佳实践
    • chunk_size调优:对于半结构化文档,一个好的起点是512到1024个token。关键是确保一个块能够包含一个相对完整的思想单元(比如一个段落或一个功能点)。
    • chunk_overlap的作用:在这里,重叠(overlap)非常重要。它像一个"安全绳",确保即使一个思想单元在块的边界被切断,它也能在下一个块中继续,从而保证了上下文的连续性。10%到20%的重叠率是一个常见的、合理的选择。

武器三:父文档检索器 (Parent Document Retriever)

这是我们武器库中的"广角+微距"镜头,专门解决一个核心矛盾:我们希望用小而精的块来做精确的语义匹配(微距),但又希望LLM能看到大而全的上下文来做高质量的回答(广角)。
工作原理:
  1. 索引阶段:我们将同一份文档切分成两种尺寸:小的"子块"(child chunks)和大的"父块"(parent chunks)。我们只将子块向量化后存入向量数据库。同时,在一个独立的文档存储(DocStore)中,我们保存父块的原文。
  1. 检索阶段:当用户查询时,我们首先在子块的向量数据库中进行搜索,找到最匹配的子块。然后,我们根据这个子块的引用,从DocStore中取出它对应的父块,最终将这个富含上下文的父块交给LLM。
代码实战:
from langchain.storage import InMemoryStore from langchain.vectorstores import FAISS from langchain.retrievers import ParentDocumentRetriever from langchain.text_splitter import RecursiveCharacterTextSplitter # 示例文档,一份API使用协议 doc_text = """ # API 使用协议 感谢您使用我们的服务。 ## 1. 定义 "API"指应用程序编程接口。 "用户"指使用本API的个人或实体。 "数据"指通过API传输的任何信息。 ## 2. 授权范围 我们授予您一项有限的、非独占的、不可转让的许可来使用本API。 您同意不进行逆向工程。安全是我们的首要任务,任何滥用行为都将导致封禁。 ### 2.1 安全限制 严禁使用API进行任何形式的DDoS攻击。 所有请求都必须使用HTTPS加密。 ## 3. 责任限制 对于因使用API导致的任何直接或间接损失,我们概不负责。 """ # 1. 创建父块切分器 (用于存储) parent_splitter = RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=0) # 2. 创建子块切分器 (用于检索) child_splitter = RecursiveCharacterTextSplitter(chunk_size=120, chunk_overlap=10) # 3. 初始化向量数据库和文档存储 vectorstore = FAISS.from_texts( texts=[doc_text], # 注意这里传入的是原始文档 embedding=embeddings ) store = InMemoryStore() # 4. 初始化父文档检索器 retriever = ParentDocumentRetriever( vectorstore=vectorstore, docstore=store, child_splitter=child_splitter, parent_splitter=parent_splitter, ) # 5. 向检索器中添加文档 (这会在后台自动完成切分和存储) retriever.add_documents([doc_text]) # 6. 测试检索效果 sub_docs = retriever.vectorstore.similarity_search("DDoS攻击") print(f"--- 匹配到的子块内容 ---\\n{sub_docs[0].page_content}\\n") retrieved_docs = retriever.get_relevant_documents("DDoS攻击") print(f"--- 最终召回的父块内容 ---\\n{retrieved_docs[0].page_content}")
召回工作流:先微距定位,后广角观察
notion image
  1. 用户查询:"关于DDoS攻击的规定是什么?"
  1. 微距定位:系统在子块向量库中进行搜索。由于子块很小(例如,只有严禁使用API进行任何形式的DDoS攻击。这一句),语义非常集中,因此能非常精确地匹配到用户的查询意图。
  1. 查找父块:系统找到了最佳匹配的子块,然后通过其ID或引用,去DocStore中找到了它所属的、未经切分的原始父块,也就是"## 2. 授权范围"这一整个大段落。
  1. 广角观察:最后,系统将这个完整的、包含丰富上下文的父块(我们授予您一项有限的...任何滥用行为都将导致封禁。)传递给LLM。
  1. 生成高质量答案:LLM不仅看到了"严禁DDoS攻击"这一核心规定,还看到了关于"安全"、"滥用"、"封禁"等相关的上下文信息,从而能够生成一个更全面、更人性化的答案,例如:"根据API使用协议,严禁使用API进行任何形式的DDoS攻击。请注意,安全是我们的首要任务,任何滥用行为都可能导致您的账户被封禁。"

Part 4: 核心策略选择指南

我们已经学习了三种强大的武器,但在实战中,应该如何选择?这并非一个"哪个最好"的问题,而是一个"哪个最适合"的问题。

4.1 决策流程

你可以根据以下决策流程来选择最适合你的策略:
notion image

4.2 策略对比总结

策略
核心优势
最适用场景
注意事项
结构化切片
精度最高,完全利用已有结构,语义不被割裂。
格式统一、结构清晰的文档,如API手册、技术规范、网站内容。
对文档格式的规范性要求高,如果文档结构混乱则效果不佳。
递归切片
通用性最强,灵活适应各种文档,是可靠的"万金油"。
大多数半结构化文档,如内部Wiki、博客文章、新闻稿。
chunk_sizechunk_overlap的设置对效果影响大,需要调优。
父文档检索
上下文最完整,解决了精确检索与全面理解的矛盾。
需要深度理解上下文才能回答的问答场景,如法律文书、研究报告。
索引和存储的复杂度稍高,需要同时维护向量库和文档库。

4.3 最终建议

  • 从结构化开始:如果你的知识库中有大量Markdown或HTML格式的文档,优先为它们实施结构化切片策略。这是最容易看到立竿见影效果的地方。
  • 以递归为基础:对于其他所有文档,从递归切片开始。它是一个非常稳健的基线,能处理绝大多数情况。
  • 按需升级:如果在特定场景下,你发现递归切片召回的上下文不足以让LLM生成高质量答案,再考虑将该场景的检索器升级为父文档检索器
在下一篇《战术篇(下)》中,我们将继续深潜,挑战企业知识中最难啃的两块骨头:高度非结构化的自由文本(如客服对话)和结构独特的代码知识。