AI 摘要

data_preparation.py 为 RAG 系统实现父子文档检索:按 Markdown 标题将食谱分块,子块精准匹配,父块提供完整上下文;自动提取分类、难度等元数据,按匹配数排序去重返回父文档,兼顾检索精度与生成质量。

📚 模块概述

这是一个用于 RAG(检索增强生成)系统的数据准备模块,专门处理食谱数据。它实现了父子文档(Parent-Child Document)的检索策略,是一种高级的 RAG 优化技术。


🎯 核心设计思想

父子文档检索模式

  • 子文档(chunks):小块文本,用于语义检索(查询匹配更精准)
  • 父文档(documents):完整食谱,用于生成回答(上下文更完整)

这种设计解决了 RAG 中的矛盾:小块检索精准但上下文不足,大块上下文完整但检索不精准


🏗️ 类结构分析

1. 静态配置(类属性)

CATEGORY_MAPPING = {
    'meat_dish': '荤菜',
    'vegetable_dish': '素菜',
    'soup': '汤品',
    # ...
}
CATEGORY_LABELS = ['荤菜', '素菜', '汤品', ...]
DIFFICULTY_LABELS = ['非常简单', '简单', '中等', '困难', '非常困难']
  • 统一维护分类和难度标签
  • 支持多语言路径到中文分类的映射
  • 可被其他模块复用(如查询构造模块)

2. 实例属性

self.documents: List[Document]      # 父文档列表
self.chunks: List[Document]         # 子文档列表
self.parent_child_map: Dict[str, str]  # 子块ID -> 父文档ID映射

🔧 核心方法详解

load_documents() - 文档加载

功能:从文件系统加载 Markdown 格式的食谱文件

关键实现

# 1. 生成确定性父文档ID(基于相对路径的MD5)
relative_path = Path(md_file).resolve().relative_to(data_root).as_posix()
parent_id = hashlib.md5(relative_path.encode("utf-8")).hexdigest()

# 2. 创建Document对象,标记为父文档
doc = Document(
    page_content=content,
    metadata={
        "source": str(md_file),
        "parent_id": parent_id,
        "doc_type": "parent"  # 关键标记
    }
)

设计亮点

  • 使用文件路径生成确定性ID(避免重复加载时ID变化)
  • 保留原始 Markdown 格式(而非纯文本)
  • 自动增强元数据

_enhance_metadata() - 元数据增强

功能:从文件路径和内容中提取结构化信息

提取逻辑

  1. 分类提取(从路径):

    # 路径示例: data/C8/cook/meat_dish/红烧肉.md
    for key, value in self.CATEGORY_MAPPING.items():
        if key in path_parts:  # 检查路径中是否包含 'meat_dish'
            doc.metadata['category'] = value  # 设置为 '荤菜'
  2. 难度提取(从内容):

    if '★★★★★' in content:
        doc.metadata['difficulty'] = '非常困难'
    elif '★★★★' in content:
        doc.metadata['difficulty'] = '困难'
    # ...
  3. 菜品名称

    doc.metadata['dish_name'] = file_path.stem  # 文件名(不含扩展名)

chunk_documents() - 文档分块

功能:将父文档分割成子块,建立父子映射关系

分块策略

# 使用 Markdown 标题层级进行结构化分割
headers_to_split_on = [
    ("#", "主标题"),      # 菜品名称
    ("##", "二级标题"),   # 必备原料、计算、操作等
    ("###", "三级标题")   # 简易版本、复杂版本等
]

父子关系建立

for i, chunk in enumerate(md_chunks):
    child_id = str(uuid.uuid4())  # 生成子块唯一ID
    
    chunk.metadata.update({
        "chunk_id": child_id,
        "parent_id": parent_id,        # 关联父文档
        "doc_type": "child",           # 标记为子文档
        "chunk_index": i               # 在父文档中的顺序
    })
    
    self.parent_child_map[child_id] = parent_id  # 建立映射

优势

  • 保留 Markdown 语义结构(按标题分割,不是按字符数)
  • 每个子块都知道自己属于哪个父文档
  • 子块保留完整标题信息(strip_headers=False

get_parent_documents() - 父文档检索

功能:根据检索到的子块,智能去重地获取对应父文档

核心算法

# 1. 统计每个父文档的相关性(被匹配的子块数量)
parent_relevance = {}
for chunk in child_chunks:
    parent_id = chunk.metadata.get("parent_id")
    parent_relevance[parent_id] = parent_relevance.get(parent_id, 0) + 1

# 2. 按相关性排序(匹配次数多的排前面)
sorted_parent_ids = sorted(parent_relevance.keys(),
                          key=lambda x: parent_relevance[x],
                          reverse=True)

# 3. 去重并返回父文档
parent_docs = [parent_docs_map[pid] for pid in sorted_parent_ids]

应用场景
假设检索到 5 个子块:

  • 红烧肉的"必备原料"块
  • 红烧肉的"操作步骤"块
  • 糖醋排骨的"必备原料"块
  • 红烧肉的"计算用量"块
  • 糖醋排骨的"操作步骤"块

结果:返回 2 个父文档:

  1. 红烧肉(相关性 3)
  2. 糖醋排骨(相关性 2)

⑤ 其他辅助方法

分类过滤

def filter_documents_by_category(self, category: str):
    return [doc for doc in self.documents 
            if doc.metadata.get('category') == category]

统计信息

def get_statistics(self):
    return {
        'total_documents': len(self.documents),
        'total_chunks': len(self.chunks),
        'categories': {...},
        'difficulties': {...},
        'avg_chunk_size': ...
    }

🔄 工作流程

1. 加载数据
   load_documents()
   └─> 读取 .md 文件
   └─> 生成 parent_id
   └─> 增强元数据(分类、难度、菜名)

2. 文档分块
   chunk_documents()
   └─> Markdown 标题分割
   └─> 为每个子块生成 chunk_id
   └─> 建立 parent_child_map

3. 检索使用
   子块 → 向量检索(精准匹配)
   └─> get_parent_documents()
       └─> 根据 parent_id 查找父文档
       └─> 智能去重和排序
       └─> 返回完整食谱

💡 设计亮点

  1. 确定性ID生成:基于文件路径的 MD5,避免重复加载时ID变化
  2. 结构化分块:按 Markdown 标题语义分割,而非简单字符数
  3. 相关性排序:父文档按匹配子块数排序,优先返回最相关的
  4. 元数据丰富:自动提取分类、难度、菜名等结构化信息
  5. 可扩展设计:类方法提供配置标签,便于其他模块复用

📊 数据流示例

输入文件data/C8/cook/meat_dish/红烧肉.md

# 红烧肉
## 必备原料
五花肉 500g...
## 操作步骤
1. 切块...

加载后父文档

{
  "page_content": "# 红烧肉\n## 必备原料...",
  "metadata": {
    "parent_id": "a3f2c1...",
    "doc_type": "parent",
    "category": "荤菜",
    "difficulty": "简单",
    "dish_name": "红烧肉"
  }
}

分块后子文档(3个):

[
  {
    "page_content": "# 红烧肉",
    "metadata": {"chunk_id": "uuid1", "parent_id": "a3f2c1...", "doc_type": "child", "chunk_index": 0}
  },
  {
    "page_content": "## 必备原料\n五花肉 500g...",
    "metadata": {"chunk_id": "uuid2", "parent_id": "a3f2c1...", "doc_type": "child", "chunk_index": 1}
  },
  {
    "page_content": "## 操作步骤\n1. 切块...",
    "metadata": {"chunk_id": "uuid3", "parent_id": "a3f2c1...", "doc_type": "child", "chunk_index": 2}
  }
]

🎓 总结

这个模块实现了 父子文档检索模式,是高级 RAG 技术的典型应用:

  • 检索阶段:用小块(子文档)提高匹配精度
  • 生成阶段:用大块(父文档)提供完整上下文

通过结构化分块、智能去重和相关性排序,在检索精度和上下文完整性之间达到最佳平衡。