至此,我们已经规划了宏伟的蓝图(战略篇),设计了智能的大脑(架构篇),并组建了专业的团队(组织篇)。现在,是时候深入一线,为我们的”专家小队”配备最精良的武器了。欢迎来到战术篇!
本篇是技术深潜的第一站,我们将聚焦于企业知识库中最常见、也最容易产生价值的一类数据:结构化与半结构化知识。这包括技术手册、API文档、网页内容、Markdown格式的内部Wiki、甚至是格式清晰的Word文档。
这类文档的共同特点是:作者已经通过标题、列表、代码块等形式,为内容赋予了清晰的逻辑结构。 我们的核心战术,就是最大化地利用这些结构信息,实现最高效、最精确的切分与检索。
本篇我们将深入讲解并实战演练三种核心武器:
-
结构化切片 (Markdown/HTML):精准拆解的”解剖刀”。
-
递归切片 (Recursive Chunking):灵活通用的”瑞士军刀”。
-
父文档检索器 (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")
<img src=“https://raw.githubusercontent.com/lilbitty007/obs_img/%E5%88%86%E6%94%AF1/img/20250627201526308.png”alt=“图片描述” style=“display: block; margin: 0 auto;” width=“600”>
结果分析与召回策略:
上面的代码会将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) 流程:
- 用户查询分析:系统可能会识别出查询中的关键词”检索组件”。
- 执行混合搜索:系统向向量数据库发起一个包含语义查询和元数据过滤的请求。
# 伪代码示意
retriever.search(
query="检索组件是做什么的?",
metadata_filter={
"Header 3": {"contains": "检索"}
}
)
- 第一步:元数据预过滤 (Pre-filtering)。向量数据库首先不过进行任何昂贵的向量计算,而是闪电般地执行元数据过滤。它会扫描所有文本块的元数据,只筛选出那些
Header 3
字段包含 “检索” 一词的块。在我们的例子中,数百万个块可能瞬间就被过滤得只剩下 块4。 - 第二步:在子集上进行语义搜索 (Semantic Search on Subset)。现在,系统只需要在块4这个极小的集合上进行语义相似度计算。这几乎没有计算成本。
- 返回精准结果:系统最终精确地返回 块4 的内容:“检索模块用于从外部数据源获取信息。“,并将其交给LLM生成最终答案。
核心优势:效率与精度的双重提升。这种”先过滤,后搜索”的模式,将结构化数据库查询的”确定性”和向量搜索的”模糊语义匹配能力”完美结合。它避免了在整个知识库中进行大海捞针式的语义搜索,极大地缩小了搜索范围,防止了其他章节中可能出现的、但相关性不高的”检索”一词的干扰,最终实现了更快、更准的召回。
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()
<img src=“https://raw.githubusercontent.com/lilbitty007/obs_img/%E5%88%86%E6%94%AF1/img/20250627202756483.png”alt=“图片描述” style=“display: block; margin: 0 auto;” width=“600”>
结果分析与召回策略:
-
切分结果:上面的代码会优先在
\\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)**的优势:
- 语义匹配:用户的查询向量在语义上会同时接近 块3(提到了”最佳实践”)和 块4(解释了”需要实验”和”平衡”)。
- 检索Top-K个块:典型的RAG系统会召回最相关的Top-K个块(比如K=2或3)。在这种情况下,系统很可能会同时召回 块3 和 块4。
- 重叠的价值:即使一个关键句子被切分开,
chunk_overlap
也能确保这个句子的上下文信息被两个块共享,这进一步增加了相关块被同时召回的概率。 - 提供完整上下文:最终,LLM会得到一组内容互补的文本块,它能从中看到”最佳实践是512-1024个token”,也能看到”但这并非绝对,需要根据情况平衡”,从而给出一个全面而准确的回答。
-
最佳实践:
-
**chunk_size**
调优:对于半结构化文档,一个好的起点是512到1024个token。关键是确保一个块能够包含一个相对完整的思想单元(比如一个段落或一个功能点)。 -
**chunk_overlap**
的作用:在这里,重叠(overlap)非常重要。它像一个”安全绳”,确保即使一个思想单元在块的边界被切断,它也能在下一个块中继续,从而保证了上下文的连续性。10%到20%的重叠率是一个常见的、合理的选择。
武器三:父文档检索器 (Parent Document Retriever)
这是我们武器库中的”广角+微距”镜头,专门解决一个核心矛盾:我们希望用小而精的块来做精确的语义匹配(微距),但又希望LLM能看到大而全的上下文来做高质量的回答(广角)。
工作原理:
-
索引阶段:我们将同一份文档切分成两种尺寸:小的”子块”(child chunks)和大的”父块”(parent chunks)。我们只将子块向量化后存入向量数据库。同时,在一个独立的文档存储(DocStore)中,我们保存父块的原文。
-
检索阶段:当用户查询时,我们首先在子块的向量数据库中进行搜索,找到最匹配的子块。然后,我们根据这个子块的引用,从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}")
召回工作流:先微距定位,后广角观察 <img src=“https://raw.githubusercontent.com/lilbitty007/obs_img/%E5%88%86%E6%94%AF1/img/20250627203402265.png”alt=“图片描述” style=“display: block; margin: 0 auto;” width=“600”>
-
用户查询:“关于DDoS攻击的规定是什么?”
-
微距定位:系统在子块向量库中进行搜索。由于子块很小(例如,只有
严禁使用API进行任何形式的DDoS攻击。
这一句),语义非常集中,因此能非常精确地匹配到用户的查询意图。 -
查找父块:系统找到了最佳匹配的子块,然后通过其ID或引用,去
DocStore
中找到了它所属的、未经切分的原始父块,也就是”## 2. 授权范围”这一整个大段落。 -
广角观察:最后,系统将这个完整的、包含丰富上下文的父块(
我们授予您一项有限的...任何滥用行为都将导致封禁。
)传递给LLM。 -
生成高质量答案:LLM不仅看到了”严禁DDoS攻击”这一核心规定,还看到了关于”安全”、“滥用”、“封禁”等相关的上下文信息,从而能够生成一个更全面、更人性化的答案,例如:“根据API使用协议,严禁使用API进行任何形式的DDoS攻击。请注意,安全是我们的首要任务,任何滥用行为都可能导致您的账户被封禁。“
Part 4: 核心策略选择指南
我们已经学习了三种强大的武器,但在实战中,应该如何选择?这并非一个”哪个最好”的问题,而是一个”哪个最适合”的问题。
4.1 决策流程
你可以根据以下决策流程来选择最适合你的策略 <img src=“https://raw.githubusercontent.com/lilbitty007/obs_img/%E5%88%86%E6%94%AF1/img/20250627203716483.png”alt=“图片描述” style=“display: block; margin: 0 auto;” width=“600”>
4.2 策略对比总结
策略 | 核心优势 | 最适用场景 | 注意事项 |
---|---|---|---|
结构化切片 | 精度最高,完全利用已有结构,语义不被割裂。 | 格式统一、结构清晰的文档,如API手册、技术规范、网站内容。 | 对文档格式的规范性要求高,如果文档结构混乱则效果不佳。 |
递归切片 | 通用性最强,灵活适应各种文档,是可靠的”万金油”。 | 大多数半结构化文档,如内部Wiki、博客文章、新闻稿。 | chunk_size 和chunk_overlap 的设置对效果影响大,需要调优。 |
父文档检索 | 上下文最完整,解决了精确检索与全面理解的矛盾。 | 需要深度理解上下文才能回答的问答场景,如法律文书、研究报告。 | 索引和存储的复杂度稍高,需要同时维护向量库和文档库。 |
4.3 最终建议
-
从结构化开始:如果你的知识库中有大量Markdown或HTML格式的文档,优先为它们实施结构化切片策略。这是最容易看到立竿见影效果的地方。
-
以递归为基础:对于其他所有文档,从递归切片开始。它是一个非常稳健的基线,能处理绝大多数情况。
-
按需升级:如果在特定场景下,你发现递归切片召回的上下文不足以让LLM生成高质量答案,再考虑将该场景的检索器升级为父文档检索器。
在下一篇《战术篇(下)》中,我们将继续深潜,挑战企业知识中最难啃的两块骨头:高度非结构化的自由文本(如客服对话)和结构独特的代码知识。