Rag chunk 之:Excel 文档解析

前言

处理 Excel 文件时会遇到一些独特的挑战。与典型的结构化格式不同,由于合并单元格、多个表头、嵌入式图表和非传统的布局(这些布局主要设计用于人阅读而非机器解析)等元素,这些文件在数据提取和处理方面存在障碍。

在处理 Excel 时可能会遇到各种 Excel 文件格式,从现代的 .xlsx 到旧版的.xls 或宏启用版的 .xlsm 文件,每种格式都需要不同的解析方法和库。跨工作表或单个工作表内的数据不一致进一步使过程复杂化。非标准文件通常缺乏统一性,呈现不同的列顺序、不一致的日期格式或列内混合的数据类型,需要强大的错误处理和数据验证机制。

合并单元格对解析算法来说尤其成问题,因为它们可以跨越多行或多列,使数据关联变得复杂。必须编写程序逻辑来准确处理这些合并区域。隐藏的行、列或工作表增加了另一层复杂性,需要彻底检查整个工作簿以确保完整的数据提取。

为应对这些挑战,必须开发稳健、灵活的解析解决方案。这通常涉及结合多种方法,例如使用专门的 Excel 解析库、为特定文件结构实现自定义逻辑,以及采用机器学习技术对半结构化数据进行模式识别。

预处理

在分块前处理合并单元格、复杂公式和非表格数据。这些属于预处理。

合并单元格问题

在对 Excel 进行分块之前,必须先把「合并单元格导致的信息缺失」消除,否则嵌入时会出现 NaN 或空字符串,严重影响检索召回。

为什么要先处理合并单元格?

我们以一个 "功能清单.xlsx" 为例:

  • 读取时只保留左上角值:无论 openpyxl 还是 pandas.read_excel,合并区域内除左上角外其余格值会被置空 。
  • 行级分块会丢字段:合并了"模块名称"或"子系统"的行,在转换成文本时会缺列,导致检索无法定位 。
  • RAG 检索依赖元数据:若模块名丢失,metadata_filter 将失效,回答准确率显著下降(内部测试下降 15--25 pp)。

解决方案

思路:

  1. 利用 ws.merged_cells.ranges 拿到所有合并区域
  2. 读取左上角值,遍历填充到区域内每个单元格。
  3. 调用 ws.unmerge_cells() 取消合并,再保存临时文件供 pandas / Unstructured 后续处理。
python 复制代码
from openpyxl import load_workbook

def explode_merged_cells(path_in: str, path_out: str):
    wb = load_workbook(path_in)
    for ws in wb.worksheets:
        for rng in list(ws.merged_cells.ranges):           # 复制,以免迭代中修改
            ws.unmerge_cells(str(rng))                     # 先解除,否则无法写值
            tl_cell = ws[rng.min_row][rng.min_col - 1]     # 左上角 Cell
            for r in range(rng.min_row, rng.max_row + 1):
                for c in range(rng.min_col, rng.max_col + 1):
                    ws.cell(r, c).value = tl_cell.value
    wb.save(path_out)

核心 API 参考:unmerge_cells() 、merged_cells

Chunking 策略

我手头有一些《功能清单》 和 《工时评估表》 就以这些文件为例,讨论一下具体的 chunking 策略,具体来说,是使用 两级分块

什么是"两级分块"?

两级分块本质固定:父 = 模块/功能域,子 = 行记录。

Azure 官方指南将这种把大块再拆子块的做法称作"层次化 chunking/hierarchical chunking",可与 Auto-Merging Retrieval 等检索算法天然配合

  • 功能清单与工时评估表本质是一条条功能点记录;行级粒度最能保持"一问就能命中一行"的高精检索。
  • 单纯行级又易丢失上下文,例如"所属子系统";用业务模块字段先聚合可在召回时带来更丰富的背景。
  • 如果某模块非常大,使用递归切分(递归字符或 token 限长)可以在不破坏结构的情况下继续拆分。

具体来说是:模块 → 行 先聚后拆,更适合 Excel 表中已有明确模块列、需要用向量库分区或 metadata 过滤的系统

处理流程上务必:

  1. 先处理合并单元格
  2. 行文本带列名
  3. metadata 保留 module 字段,以便精准过滤与 Auto-Merging 聚合。 而元数据的处理(模块、行号、sheet 名),决定了查询过滤与答案上下文的可控性。

详细说明

两级分块中的 父和子,具体来说是:"摘要型父块 + 行级子块"

父块

  • 父块的存在价值在于提供业务背景 + 索引锚点。比如 "模块 A:支付结算;记录 426 行" 。
  • 父块采用"header + 小块"策略

为什么父块可只包含结构信息?

  • 父块不包含子行信息,因为子块检索命中后,通过父 ID 回溯获得模块级上下文,提高回答完整度。
  • 在层次化 (hierarchical) 分块体系里,"父块 (parent document)" 的核心职责是让检索器知道一批子块属于哪 个业务语境,而不是存放子块的全文内容。
  • 通常父块只保存模块级背景(例如模块名、描述、记录数等),不再内嵌每一行子块的具体文本;这样既保上下文,又避免重复嵌入

代码:父块仅含结构信息的实现

python 复制代码
import pandas as pd
from langchain.docstore.document import Document
from langchain.text_splitter import RecursiveCharacterTextSplitter

df = pd.read_excel("functions_flat.xlsx", engine="openpyxl")

# ➊ 生成父块------只保背景
parent_docs = [
    Document(page_content=f"模块名称: {m}\n总记录数: {len(sub)}",
             metadata={"module": m, "level": "parent"})
    for m, sub in df.groupby("模块")
]

# ➋ 生成子块------行文本
row_docs = []
for _, row in df.iterrows():
    md = "\n".join(f"**{c}**: {v}" for c, v in row.items())
    row_docs.append(Document(page_content=md,
                             metadata={"module": row['模块'], "level": "child"}))

# ➌ 可选:对子块再递归切分,确保 <2048 chars
splitter = RecursiveCharacterTextSplitter(chunk_size=2048, chunk_overlap=256)
child_chunks = splitter.split_documents(row_docs)

ParentDocumentRetriever 在检索时会先命中 child_chunks,随后自动用父 ID 把对应模块摘要拼回上下文。如需"关键列拼接"模式,只需把 row[['ID','Name']] 等字段 join 到父块内容。

子块

在"模块 → 行"层次化分块里,子块(child chunk)就是把 Excel 中"一行业务记录"转成能让向量检索与 LLM 都看得懂的最小语义单元。它既要携带行内全部有效信息,又不能冗余到超出模型窗口。

子块典型 Markdown/JSON 结构:

md 复制代码
## 模块: 支付结算
**功能ID**: PAY-001  
**功能名称**: 创建收款单  
**功能类型**: 核心  
**COSMIC FP**: 6  
json 复制代码
{
  "module": "支付结算",
  "功能ID": "PAY-001",
  "功能名称": "创建收款单",
  "功能类型": "核心",
  "COSMIC_FP": 6
}

推荐生成流程(代码片段):

python 复制代码
import pandas as pd
from langchain.docstore.document import Document
from langchain.text_splitter import RecursiveCharacterTextSplitter

df = pd.read_excel("functions_flat.xlsx", engine="openpyxl")

child_docs = []
for idx, row in df.iterrows():
    module = row["模块"]
    # ------ 1) 行→Markdown
    body = "\n".join(f"**{c}**: {v}" for c, v in row.items())
    # ------ 2) 写入 Document
    child_docs.append(
        Document(
            page_content=f"# 模块: {module}\n{body}",
            metadata={
                "module": module,
                "row_id": idx + 2,           # Excel 行号(含表头补1)
                "sheet": "功能清单"
            }
        )
    )

# 3) 控制长度,避免超窗口
splitter = RecursiveCharacterTextSplitter(
    chunk_size=2048, chunk_overlap=256
)
child_chunks = splitter.split_documents(child_docs)

标题行

  • 行级子块一定写列名-值对
  • 父块按需保存一次表头或仅存摘要

父子块生成策略

在 Excel → 向量库的 RAG 管道里,最省事、也最被 LangChain/LlamaIndex/Haystack 等工具链推荐的做法,就是 "在同一遍遍历中同时生成父块和子块,并用 module 或 parent_id 把两者关联起来"。这样既避免二次扫描,又保证所有子块天生带着正确的父信息,检索器便能先召回精确的行级子块,再顺着指针把对应的模块级父块自动补进上下文,实现"精召回 + 背景补全"的最佳组合。

一遍循环生成父子块的核心流程

步骤 0:展开合并单元格 用 openpyxl 的 unmerge_cells 把合并区域拆开,再把左上角值填满整块;或用 pandas.ffill() 向下补齐。这样每行都能拿到正确的 模块 字段

步骤 1:遍历行 → 同时产出父块与子块

python 复制代码
import pandas as pd
from langchain.docstore.document import Document
from langchain.text_splitter import RecursiveCharacterTextSplitter

df = pd.read_excel("functions_flat.xlsx", engine="openpyxl").ffill()
parent_seen, parents, children = {}, [], []
splitter = RecursiveCharacterTextSplitter(chunk_size=2048, chunk_overlap=256)  # 控长:contentReference[oaicite:7]{index=7}

for idx, row in df.iterrows():
    mod = row["模块"]              # ① 遇到新模块先建父块
    if mod not in parent_seen:
        parent_seen[mod] = Document(
            page_content=f"模块: {mod}",
            metadata={"module": mod, "level": "parent"}
        )
        parents.append(parent_seen[mod])

    body = "\n".join(f"**{c}**: {v}" for c, v in row.items())  # ② 行→Markdown,保留列名
    child = Document(
        page_content=body,
        metadata={
            "module": mod,
            "parent_id": id(parent_seen[mod]),   # 或直接存 module
            "row": idx + 2
        }
    )
    children.extend(splitter.split_documents([child]))         # 行过长再递归切
  • 父块 只存简短摘要(如模块名、记录数),避免重复嵌入。
  • 子块 带齐列名-值对、行号及父引用,保证可追溯。社区经验贴也强调"列名+值"比裸值更利于语义检索

步骤 2:写入向量库 只向量化 子块,将 module 作为 partition key 或 metadata。父块可放旁路索引,或与子块一同存但不做向量化。

步骤 3:检索时自动拼接 用 LangChain ParentDocumentRetriever、LlamaIndex AutoMergingRetriever 或 Haystack Auto-Merging Retriever:

  1. 先做向量检索拿到 k 个子块;
  2. 按 parent_id/module 查父块;
  3. 拼 "父摘要 + 命中子块(±近邻)" 送入 LLM。

检索流程

  • 过滤:查询时先用 filter={"module": <候选模块>} 做向量库精搜;Milvus 文档示例说明 filtered search 会先裁剪候选集再做 ANN,比全库检索快 2-4×
  • Auto-Merging:若一次命中同模块多行,LlamaIndex/Haystack 会把这些行和父摘要合并,避免窗口碎片化

注意事项

其他 chunking 策略

  • 基于工作表和基于行的分块
  • 基于列的拆分
  • 混合与滑动窗口技术

用于 Excel 分块的工具和库

pandas

Python 的 pandas 库是许多 Excel 处理任务的核心,为读取 Excel 文件提供了强大的分块支持。 read_excel() 函数的 chunksize 参数允许进行内存高效、固定大小的分块

openpyxl

对于更复杂的 Excel 结构,openpyxl 库提供了对 Excel 文件解析的粒度控制,使其适用于基于内容的分块方法,能够有效处理合并单元格、公式和其他非标准元素。

xlrd

xlrd 库虽然主要针对较旧的.xls 格式,但对于遗留系统仍然具有相关性,并提供快速解析功能,在混合分块方法中,当速度至关重要时,这些功能非常有用。

相关推荐
Fireworkitte10 小时前
Apache POI 详解 - Java 操作 Excel/Word/PPT
java·apache·excel
Prodigy_kyw15 小时前
VBA初学3----实战(VBA实现Excel转csv)
excel·vba·csv
红衣女妖仙18 小时前
JXLS 库导出复杂 Excel
java·excel·jxls·java 导出 excel
吃我两拳21 小时前
EasyExcel停止当前Sheet的读取,且不影响主线程及其他Sheet读取的方法
excel
qq_393828221 天前
办公文档批量打印器 Word、PPT、Excel、PDF、图片和文本,它都支持批量打印。
windows·word·powerpoint·excel·软件需求
过期的秋刀鱼!1 天前
用“做饭”理解数据分析流程(Excel三件套实战)
数据挖掘·数据分析·excel·powerbi·数据分析入门
挑战者6668881 天前
如何将Excel表的内容转化为json格式呢?
excel
干净的坏蛋1 天前
EasyExcel实现Excel复杂格式导出:合并单元格与样式设置实战
excel
张太行_11 天前
MySQL与Excel比较
数据库·mysql·excel
cwtlw11 天前
Excel学习03
笔记·学习·其他·excel