至此,我们已经规划了宏伟的蓝图(战略篇),设计了智能的大脑(架构篇),并组建了专业的团队(组织篇)。现在,是时候深入一线,为我们的"专家小队"配备最精良的武器了。欢迎来到战术篇!
本篇是技术深潜的第一站,我们将聚焦于企业知识库中最常见、也最容易产生价值的一类数据:结构化与半结构化知识。这包括技术手册、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")
结果分析与召回策略:
上面的代码会将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生成最终答案。
核心优势:效率与精度的双重提升。这种"先过滤,后搜索"的模式,将结构化数据库查询的"确定性"和向量搜索的"模糊语义匹配能力"完美结合。它避免了在整个知识库中进行大海捞针式的语义搜索,极大地缩小了搜索范围,防止了其他章节中可能出现的、但相关性不高的"检索"一词的干扰,最终实现了更快、更准的召回。
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()
结果分析与召回策略:
- 切分结果:上面的代码会优先在
\\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关键在于平衡。
- 召回工作流:依靠语义和重叠
- 语义匹配:用户的查询向量在语义上会同时接近 块3(提到了"最佳实践")和 块4(解释了"需要实验"和"平衡")。
- 检索Top-K个块:典型的RAG系统会召回最相关的Top-K个块(比如K=2或3)。在这种情况下,系统很可能会同时召回 块3 和 块4。
- 重叠的价值:即使一个关键句子被切分开,
chunk_overlap
也能确保这个句子的上下文信息被两个块共享,这进一步增加了相关块被同时召回的概率。 - 提供完整上下文:最终,LLM会得到一组内容互补的文本块,它能从中看到"最佳实践是512-1024个token",也能看到"但这并非绝对,需要根据情况平衡",从而给出一个全面而准确的回答。
假设用户提问:"chunk_size的最佳实践是什么,为什么说它需要平衡?"
递归切片的召回依赖于标准的语义搜索,并巧妙地利用了**块重叠(chunk overlap)**的优势:
- 最佳实践:
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}")
召回工作流:先微距定位,后广角观察
- 用户查询:"关于DDoS攻击的规定是什么?"
- 微距定位:系统在子块向量库中进行搜索。由于子块很小(例如,只有
严禁使用API进行任何形式的DDoS攻击。
这一句),语义非常集中,因此能非常精确地匹配到用户的查询意图。
- 查找父块:系统找到了最佳匹配的子块,然后通过其ID或引用,去
DocStore
中找到了它所属的、未经切分的原始父块,也就是"## 2. 授权范围"这一整个大段落。
- 广角观察:最后,系统将这个完整的、包含丰富上下文的父块(
我们授予您一项有限的...任何滥用行为都将导致封禁。
)传递给LLM。
- 生成高质量答案:LLM不仅看到了"严禁DDoS攻击"这一核心规定,还看到了关于"安全"、"滥用"、"封禁"等相关的上下文信息,从而能够生成一个更全面、更人性化的答案,例如:"根据API使用协议,严禁使用API进行任何形式的DDoS攻击。请注意,安全是我们的首要任务,任何滥用行为都可能导致您的账户被封禁。"
Part 4: 核心策略选择指南
我们已经学习了三种强大的武器,但在实战中,应该如何选择?这并非一个"哪个最好"的问题,而是一个"哪个最适合"的问题。
4.1 决策流程
你可以根据以下决策流程来选择最适合你的策略:
4.2 策略对比总结
策略 | 核心优势 | 最适用场景 | 注意事项 |
结构化切片 | 精度最高,完全利用已有结构,语义不被割裂。 | 格式统一、结构清晰的文档,如API手册、技术规范、网站内容。 | 对文档格式的规范性要求高,如果文档结构混乱则效果不佳。 |
递归切片 | 通用性最强,灵活适应各种文档,是可靠的"万金油"。 | 大多数半结构化文档,如内部Wiki、博客文章、新闻稿。 | chunk_size 和chunk_overlap 的设置对效果影响大,需要调优。 |
父文档检索 | 上下文最完整,解决了精确检索与全面理解的矛盾。 | 需要深度理解上下文才能回答的问答场景,如法律文书、研究报告。 | 索引和存储的复杂度稍高,需要同时维护向量库和文档库。 |
4.3 最终建议
- 从结构化开始:如果你的知识库中有大量Markdown或HTML格式的文档,优先为它们实施结构化切片策略。这是最容易看到立竿见影效果的地方。
- 以递归为基础:对于其他所有文档,从递归切片开始。它是一个非常稳健的基线,能处理绝大多数情况。
- 按需升级:如果在特定场景下,你发现递归切片召回的上下文不足以让LLM生成高质量答案,再考虑将该场景的检索器升级为父文档检索器。
在下一篇《战术篇(下)》中,我们将继续深潜,挑战企业知识中最难啃的两块骨头:高度非结构化的自由文本(如客服对话)和结构独特的代码知识。