3. 知识库构建基础

文档解析和分块,是 RAG 系统的第一道关卡。说白了,这一步要是没做好,后面检索、生成全是白搭。我见过太多团队花大把时间调模型,结果问题出在最开始的文档读取上——PDF 里文字乱码、Markdown 表格被拆散、HTML 标签没清理干净……这些坑,我基本都踩过一遍。

今天咱们就把这块彻底讲透。从三种主流文档格式的解析,到三种核心分块策略的实战,我会把项目中积累的经验和教训都摊开来说。

3.1 文档解析:三种格式的实战处理

先聊解析。你想想看,知识库里的文档千奇百怪,但最常见的无非三种:PDF、Markdown、HTML。每种格式都有自己的脾气,处理方式完全不同。

3.1.1 PDF 解析:最头疼的格式

PDF 这玩意儿,设计初衷就不是为了给机器读的。它存的是「页面长什么样」,而不是「内容是什么」。所以解析 PDF 时,我建议你根据文档类型选工具:

文档类型 推荐工具 适用场景 注意点
文字型 PDF PyMuPDF (fitz) 论文、报告、合同 速度快,保留文本结构
扫描型 PDF OCR (pytesseract / PaddleOCR) 扫描件、图片型文档 需要先转图片再识别
复杂排版 PDF pdfplumber 表格、多栏布局 能提取表格坐标信息
混合型 PDF Unstructured.io 多种元素混合 自动检测元素类型

我个人习惯用 PyMuPDF 做第一道处理。它轻量、速度快,而且能保留文本的层级信息。来看个实际例子:

import fitz  # PyMuPDF

def extract_pdf_text(pdf_path):
    doc = fitz.open(pdf_path)
    full_text = []
    
    for page_num, page in enumerate(doc):
        # 提取文本块,保留位置信息
        blocks = page.get_text("blocks")
        page_text = []
        
        for block in blocks:
            # block[0:4] 是坐标,block[4] 是文本内容
            if block[6] == 0:  # 0 表示文本块
                page_text.append(block[4])
        
        full_text.append({
            "page": page_num + 1,
            "content": "\n".join(page_text)
        })
    
    return full_text
我曾经踩过的坑: 用 PyMuPDF 解析一个带水印的 PDF,结果水印文字和正文混在一起。后来加了坐标过滤——只提取页面中心区域的文本块,才解决问题。所以解析 PDF 时,一定要先抽样检查,别直接全量跑。

3.1.2 Markdown 解析:保留结构是关键

Markdown 相对友好,因为它本身就是结构化文本。但问题在于——很多 RAG 系统把 Markdown 当纯文本处理,结果标题、列表、代码块全混在一起,语义全丢了。

我建议的做法是:用 markdown-it 或 mistune 这类解析器,先把 Markdown 转成 AST(抽象语法树),再按节点类型提取内容。

import mistune

def parse_markdown_with_structure(md_text):
    # 使用 mistune 解析 Markdown
    markdown = mistune.create_markdown(renderer='ast')
    tokens = markdown(md_text)
    
    structured_content = []
    for  in tokens:
        if ['type'] == 'heading':
            structured_content.append({
                "type": "heading",
                "level": ['attrs']['level'],
                "content": ['children'][0]['raw']
            })
        elif ['type'] == 'paragraph':
            structured_content.append({
                "type": "paragraph",
                "content": ['children'][0]['raw']
            })
        elif ['type'] == 'code':
            structured_content.append({
                "type": "code",
                "language": .get('info', ''),
                "content": ['raw']
            })
    
    return structured_content
小技巧: 解析 Markdown 时,保留标题层级(h1-h6)非常重要。后续做语义分块时,标题就是天然的「分割点」。

3.1.3 HTML 解析:清理标签,保留语义

HTML 解析的核心矛盾是:标签太多,噪声太大。你想想看,一个网页里可能有一半是导航、广告、侧边栏,真正的内容就中间那一块。

我的做法分三步:

  1. 提取主内容:用 readability-lxml 或 trafilatura 这类工具,自动识别文章主体区域
  2. 清理标签:去掉 script、style、nav、footer 等无关元素
  3. 保留结构标签:h1-h6、p、ul/ol、table、pre 这些标签保留,其他全部转成纯文本
from bs4 import BeautifulSoup

def clean_html_content(html_text):
    soup = BeautifulSoup(html_text, 'html.parser')
    
    # 移除无关元素
    for tag in soup(['script', 'style', 'nav', 'footer', 'aside']):
        tag.decompose()
    
    # 保留的结构标签
    preserved_tags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 
                      'p', 'ul', 'ol', 'li', 'table', 'pre', 'code']
    
    result = []
    for element in soup.find_all(preserved_tags):
        tag_name = element.name
        text = element.get_text(strip=True)
        if text:
            result.append(f"<{tag_name}>{text}</{tag_name}>")
    
    return "\n".join(result)

3.2 分块策略:三种核心方法实战

文档解析完了,接下来就是分块。分块这事儿,说白了就是「把长文档切成适合检索的小段」。切得太碎,语义不完整;切得太粗,检索精度下降。这里我重点讲三种策略。

3.2.1 固定大小分块:简单但有效

固定大小分块是最直接的方法——按字符数或 数切分。优点是实现简单,缺点是容易切断语义。

def fixed_size_chunking(text, chunk_size=500, overlap=50):
    """固定大小分块,带重叠"""
    chunks = []
    start = 0
    
    while start < len(text):
        end = start + chunk_size
        chunk = text[start:end]
        chunks.append(chunk)
        start = end - overlap  # 重叠部分
    
    return chunks
关键参数:
  • chunk_size:一般 200-1000 之间,取决于你的检索模型
  • overlap:10%-20% 的重叠,避免关键信息被切在边界上

我在项目中遇到过一个问题:固定分块把一段代码的 for 循环切成了两半,结果检索到的代码片段根本没法用。所以后来我加了个规则——如果切分点在代码块内,就调整到代码块结束位置。

3.2.2 递归分块:LangChain 的经典方案

递归分块是 LangChain 里 RecursiveCharacterTextSplitter 的核心思路。它不像固定分块那样一刀切,而是按「分隔符优先级」递归地切分。

优先级顺序一般是:\n\n > \n > 。 > , > 空格

def recursive_chunking(text, chunk_size=500, chunk_overlap=50):
    separators = ["\n\n", "\n", "。", ",", " ", ""]
    
    def _split(text, separators):
        if len(text) <= chunk_size:
            return [text]
        
        sep = separators[0]
        if sep == "":
            # 没有分隔符了,直接按字符切
            return [text[i:i+chunk_size] 
                    for i in range(0, len(text), chunk_size - chunk_overlap)]
        
        # 按当前分隔符切分
        parts = text.split(sep)
        chunks = []
        current_chunk = ""
        
        for part in parts:
            if len(current_chunk) + len(part) + len(sep) <= chunk_size:
                current_chunk += (sep if current_chunk else "") + part
            else:
                if current_chunk:
                    chunks.append(current_chunk)
                current_chunk = part
        
        if current_chunk:
            chunks.append(current_chunk)
        
        # 如果切出来的块还是太大,递归用下一个分隔符
        result = []
        for chunk in chunks:
            if len(chunk) > chunk_size:
                result.extend(_split(chunk, separators[1:]))
            else:
                result.append(chunk)
        
        return result
    
    return _split(text, separators)
为什么递归分块好用? 因为它优先在段落边界切分,实在不行才在句子中间切。这样切出来的块,语义完整性比固定分块好得多。

3.2.3 语义分块:最智能但最复杂

语义分块,说白了就是「让模型自己决定在哪里切」。它不依赖固定的字符数,而是根据内容的语义边界来分。

常见的实现方式有两种:

  • 基于嵌入相似度:计算相邻句子的向量相似度,相似度骤降的地方就是分块点
  • 基于主题分割:用 TextTiling 算法或 LLM 检测主题变化
from sentence_transformers import SentenceTransformer
import numpy as np

def semantic_chunking(sentences, model_name='all-MiniLM-L6-v2', threshold=0.3):
    """基于嵌入相似度的语义分块"""
    model = SentenceTransformer(model_name)
    
    # 计算每个句子的嵌入
    embeddings = model.encode(sentences)
    
    # 计算相邻句子的余弦相似度
    similarities = []
    for i in range(len(embeddings) - 1):
        sim = np.dot(embeddings[i], embeddings[i+1]) / (
            np.linalg.norm(embeddings[i]) * np.linalg.norm(embeddings[i+1])
        )
        similarities.append(sim)
    
    # 找到相似度骤降的位置
    chunk_boundaries = [0]
    for i, sim in enumerate(similarities):
        if sim < threshold:
            chunk_boundaries.append(i + 1)
    chunk_boundaries.append(len(sentences))
    
    # 按边界切分
    chunks = []
    for i in range(len(chunk_boundaries) - 1):
        start = chunk_boundaries[i]
        end = chunk_boundaries[i + 1]
        chunks.append(" ".join(sentences[start:end]))
    
    return chunks
注意: 语义分块的计算成本很高。如果文档量很大(比如上万份),建议先用递归分块做粗切,再对每个块做语义精切。我有个项目就是这么干的——速度提升了 5 倍,效果只差了 2%。

3.3 三种策略对比与选型建议

三种策略各有优劣,我整理了一张对比表:

策略 优点 缺点 适用场景
固定大小分块 实现简单,速度快 容易切断语义 日志、代码、结构化数据
递归分块 语义完整性好,通用性强 对分隔符质量敏感 文章、报告、通用文档
语义分块 最智能,边界最自然 计算成本高,依赖模型 长文档、学术论文、书籍

我个人建议的选型路径是:

  1. 先试递归分块,它 80% 的场景都够用
  2. 如果文档结构特别规整(比如 Markdown 有清晰标题),用固定分块 + 标题锚点
  3. 只有处理长文档(论文、书籍)时,才上语义分块

3.4 本章知识体系总览

下面这张图,把本章的核心逻辑串起来了。从文档输入到分块输出,每一步的输入输出和关键决策点都标清楚了:

文档解析与分块策略 · 知识体系总览 文档输入 PDF Markdown HTML 解析策略 PyMuPDF (PDF) mistune (MD) BeautifulSoup (HTML) 分块策略 固定大小 分块 递归 分块 语义 分块 结构化 Chunks → 向量库 关键决策点 1. 文档类型判断 文字型 vs 扫描型 vs 复杂排版 2. 解析工具选型 轻量快速 vs 功能全面 vs 精度优先 3. 分块策略选择 简单场景 → 固定分块 通用场景 → 递归分块 长文档 → 语义分块 4. 参数调优 chunk_size: 200-1000 overlap: 10%-20% 5. 质量验证 抽样检查 chunk 语义完整性 检索测试:Top-K 命中率 6. 避坑指南 PDF 水印过滤、代码块保护 Markdown 标题层级保留

3.5 实战建议:从解析到分块的完整流程

最后,我分享一下在实际项目中跑通的完整流程。这个流程经过多次迭代,目前比较稳定:

  1. 文档预处理:统一编码(UTF-8),去除 BOM 头,修复常见乱码
  2. 格式识别:根据文件扩展名和 MIME 类型,自动选择解析器
  3. 内容提取:按 3.1 节的方法提取结构化内容
  4. 元数据注入:给每个 chunk 打上来源、页码、标题路径等标签
  5. 分块执行:优先递归分块,特殊场景切换策略
  6. 质量检查:随机抽 5% 的 chunk 人工检查语义完整性
核心原则: 解析和分块不是一次性的工作。上线后要持续监控检索效果,如果发现某些 query 召回率低,回头检查对应文档的 chunk 质量——问题往往出在最开始那几步。