RAG系统文档解析

一、文档格式全覆盖解析方案

客户的数据构成:

| 格式 | 数量 | 占比 | 最大难点 |

|------|------|------|---------|

| PDF(文本型) | 4.2万份 | 35% | 表格提取、多栏排版 |

| PDF(扫描型) | 1.8万份 | 15% | OCR准确性、版面还原 |

| Word(.docx) | 3.1万份 | 26% | 内嵌对象、复杂样式 |

| Excel | 1.5万份 | 12% | 多sheet、合并单元格 |

| PPT | 0.8万份 | 7% | 文本框顺序 |

| 纯文本/HTML | 0.6万份 | 5% | 格式清洗 |

1.1 文本型PDF:PDFPlumber + 坐标还原

最开始用PyPDF2,代码简洁但效果极差。一份双栏排版的PDF解析出来,左栏第一行后面跟着右栏第一行,再跟左栏第二行,阅读顺序完全错乱。

PDFPlumber能获取每个字符的坐标信息,核心是利用坐标还原阅读顺序:

```python

import pdfplumber

def parse_pdf_with_layout(pdf_path: str) -> str:

text_blocks = \[\]

with pdfplumber.open(pdf_path) as pdf:

for page in pdf.pages:

获取页面尺寸

page_width = page.width

page_height = page.height

提取所有文本块及其位置

words = page.extract_words(

x_tolerance=3, # 水平合并容差

y_tolerance=3, # 垂直合并容差

keep_blank_chars=False,

use_text_flow=True # 按阅读顺序排列

)

按y坐标排序(从上到下)

words_sorted = sorted(words, key=lambda w: (w'top', w'x0'))

按行聚合

current_line = \[\]

current_top = None

for w in words_sorted:

if current_top is None or abs(w'top' - current_top) < 5:

current_line.append(w'text')

else:

text_blocks.append(' '.join(current_line))

current_line = w\['text']

current_top = w'top'

if current_line:

text_blocks.append(' '.join(current_line))

return '\n'.join(text_blocks)

```

x_tolerance和y_tolerance两个参数是关键。默认值2,但对于字号较大的标题需要调到3-5,否则同一个词会被拆成多个字符。调参依据:看解析出来的文本里有没有"今 天 天 气 不 错"这种被空格隔开的词,有就调大x_tolerance。

**表格处理**:PDFPlumber的extract_table()对有线表格效果不错,但对无线表格(用空格对齐的那种)基本无效。我的方案是先尝试extract_table(),如果返回的表格行数少于3行,改用extract_text()按坐标自己解析:

```python

def extract_table_smart(page) -> ListList\[str]:

table = page.extract_table({

'vertical_strategy': 'lines', # 有线表格用线条

'horizontal_strategy': 'lines'

})

if table and len(table) > 3:

return table

无线表格:用文本坐标推断列边界

words = page.extract_words()

if not words:

return \[\]

按x坐标聚类找到列边界

x_coords = sorted(set(w\['x0' for w in words]))

聚类算法找列

from sklearn.cluster import DBSCAN

import numpy as np

x_clusters = DBSCAN(eps=10, min_samples=5).fit(np.array(x_coords).reshape(-1, 1))

... 根据聚类结果切分每行文本

```

这个方法能覆盖90%以上的表格,剩下10%的复杂表格人工处理或直接转图片存为附件。

1.2 扫描型PDF:PaddleOCR + 版面分析

扫描PDF本质是图片集合。测试过Tesseract、EasyOCR、PaddleOCR三个方案:

| OCR方案 | 中文准确率 | 表格识别 | 速度(页/秒) |

|---------|-----------|---------|------------|

| Tesseract(中文训练) | 72% | 差 | 0.5 |

| EasyOCR | 81% | 一般 | 0.2 |

| PaddleOCR | 93% | 好 | 0.3 |

PaddleOCR胜出。关键是用它的版面分析模型PP-Structure,能区分正文、标题、表格、图片四种区域:

```python

from paddleocr import PaddleOCR

ocr = PaddleOCR(

use_angle_cls=True, # 启用方向分类

lang='ch',

use_gpu=True,

show_log=False,

det_db_thresh=0.3, # 检测阈值

det_db_box_thresh=0.5,

rec_batch_num=6 # 批量识别,提高速度

)

def ocr_with_layout(image_path: str) -> dict:

result = ocr.ocr(image_path, cls=True)

result结构: \[\[坐标, (文字, 置信度)], ...]

texts = \[\]

for line in result0:

text = line10

confidence = line11

if confidence > 0.5: # 过滤低置信度

texts.append(text)

return {

'text': '\n'.join(texts),

'raw_result': result

}

```

置信度阈值设0.5是反复试出来的。设太高会漏掉模糊但正确的文字,设太低会引入一堆乱码。

**性能优化**:1.8万份扫描PDF,每份平均15页,单机跑完需要约45天。最终用Ray做了分布式,6台机器并行:

```python

import ray

@ray.remote(num_gpus=0.5)

def process_pdf_remote(pdf_path):

每个worker跑一个PDF

return process_scanned_pdf(pdf_path)

分布式调度

futures = process_pdf_remote.remote(p) for p in pdf_list

results = ray.get(futures)

```

6台机器实际跑了7天完成全量处理。

1.3 Word文档:python-docx + 深度解包

Word的主要坑是内嵌对象(嵌入的Excel图表、Visio流程图)无法直接读取。

```python

from docx import Document

import zipfile

import xml.etree.ElementTree as ET

def parse_docx(docx_path: str) -> dict:

doc = Document(docx_path)

1. 正文段落

paragraphs = p.text for p in doc.paragraphs if p.text.strip()

2. 表格

tables = \[\]

for table in doc.tables:

table_data = \[\]

for row in table.rows:

row_data = cell.text.strip() for cell in row.cells

table_data.append(row_data)

tables.append(table_data)

3. 内嵌对象(解压docx)

embedded_texts = \[\]

with zipfile.ZipFile(docx_path, 'r') as zf:

for name in zf.namelist():

word/embeddings/ 目录下是内嵌对象

if name.startswith('word/embeddings/') and name.endswith('.xlsx'):

解压并解析Excel

excel_data = zf.read(name)

用pandas读取

df = pd.read_excel(io.BytesIO(excel_data), sheet_name=None)

for sheet_name, sheet_df in df.items():

embedded_texts.append(f"表格{sheet_name}:\n{sheet_df.to_string()}")

return {

'paragraphs': paragraphs,

'tables': tables,

'embedded': embedded_texts

}

```

**样式信息**:标题级别通过p.style.name获取,用于后续的语义切分:

```python

for p in doc.paragraphs:

style = p.style.name

if 'Heading 1' in style:

level = 1

elif 'Heading 2' in style:

level = 2

...

```

1.4 Excel:多sheet + 合并单元格处理

```python

def parse_excel(excel_path: str) -> dict:

all_sheets = {}

xl = pd.ExcelFile(excel_path)

for sheet_name in xl.sheet_names:

读取时保留合并单元格信息

df = pd.read_excel(excel_path, sheet_name=sheet_name, header=None)

前向填充合并单元格(NaT/NaN用前值填充)

df = df.fillna(method='ffill', axis=0)

df = df.fillna(method='ffill', axis=1)

清洗空行空列

df = df.dropna(how='all')

df = df.dropna(how='all', axis=1)

转成文本,保留行列结构提示

text_parts = f"=== {sheet_name} ==="

for idx, row in df.iterrows():

row_text = ' | '.join(str(c) if pd.notna(c) else '' for c in row)

text_parts.append(row_text)

all_sheetssheet_name = '\n'.join(text_parts)

return all_sheets

```

1.5 统一输出格式:为什么选Markdown

所有格式解析完成后,统一输出为Markdown。选Markdown的原因:

  1. **标题层级**:`# ## ###`天然表示文档结构,切分时可利用

  2. **表格语法**:`| col1 | col2 |` 在LLM中比纯文本表格更容易被理解

  3. **代码块**:技术文档中的代码、命令可以保留

  4. **LLM友好**:训练语料中大量Markdown,模型熟悉这种格式

转换示例:

```python

def to_markdown(parsed: dict, source_type: str) -> str:

md_parts = \[\]

if source_type == 'pdf':

根据标题样式推断层级

for line in parsed'text'.split('\n'):

if is_title(line):

level = detect_heading_level(line)

md_parts.append(f"{'#' * level} {clean_title(line)}")

else:

md_parts.append(line)

elif source_type == 'docx':

for p in parsed'paragraphs':

if p.get('style', '').startswith('Heading'):

level = int(p'style'.split()-1)

md_parts.append(f"{'#' * level} {p'text'}")

else:

md_parts.append(p'text')

表格转markdown

for table in parsed'tables':

md_parts.append(table_to_markdown(table))

return '\n\n'.join(md_parts)

```

二、智能切分方案迭代

2.1 第一版:固定长度切分(失败)

最初的方案:

```python

def fixed_chunk(text: str, chunk_size=512, overlap=50):

words = text.split()

chunks = \[\]

for i in range(0, len(words), chunk_size - overlap):

chunk = ' '.join(wordsi:i + chunk_size)

chunks.append(chunk)

return chunks

```

**失败表现**:

  • 一段技术参数"设备型号MC-2023,额定功率7.5kW,工作温度范围-10℃~60℃"被切成了两半

  • 检索"额定功率"时,包含这个信息的chunk只有后半段,但前半段包含了设备型号,导致召回失败

  • 20%的测试query因此漏召回

2.2 第二版:按标题切分(部分成功)

利用Markdown的标题层级:

```python

def split_by_headers(text: str) -> ListDict:

按# ## ###切分

sections = \[\]

current_header = None

current_content = \[\]

for line in text.split('\n'):

if re.match(r'^#{1,6}\s+', line):

新标题,保存上一节

if current_content:

sections.append({

'header': current_header,

'content': '\n'.join(current_content)

})

current_header = line

current_content = \[\]

else:

current_content.append(line)

if current_content:

sections.append({

'header': current_header,

'content': '\n'.join(current_content)

})

return sections

```

**问题**:很多文档没有标题结构(如Excel导出的数据、扫描PDF的纯文本),按标题切分失效。

2.3 最终版:语义切分 + 父子文档

**核心逻辑**:

  1. 先用句子边界做初切

  2. 再用语义相似度决定是否合并

  3. 构建父子两层chunk结构

```python

import re

from sentence_transformers import SentenceTransformer

class SemanticChunker:

def init(self, min_size=200, max_size=800, overlap=50):

self.min_size = min_size

self.max_size = max_size

self.overlap = overlap

self.encoder = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')

def split(self, text: str, title: str = '') -> ListDict:

1. 句子切分(中文句子边界)

sentences = self._split_sentences(text)

2. 如果文档有标题结构,先按标题分

if title:

sections = self._split_by_headers(text)

else:

sections = {'header': '', 'content': text}

3. 对每个section做语义合并

chunks = \[\]

for section in sections:

chunk_chunks = self._semantic_merge(section'content')

for c in chunk_chunks:

chunks.append({

'content': c,

'header': section'header',

'type': 'parent' if len(c) > 500 else 'child'

})

return chunks

def _split_sentences(self, text: str) -> Liststr:

中文句子边界:句号、问号、感叹号、换行

pattern = r'(?<=。!?!\\n)\s*'

sents = re.split(pattern, text)

return s.strip() for s in sents if s.strip()

def _semantic_merge(self, sentences: Liststr) -> Liststr:

if not sentences:

return \[\]

计算每句的embedding

if len(sentences) == 1:

return sentences

embs = self.encoder.encode(sentences)

贪心合并:相似度高的相邻句子合并

chunks = \[\]

current_chunk = sentences\[0]

current_len = len(sentences0)

for i in range(1, len(sentences)):

计算当前句与上一句的相似度

sim = cosine_similarity(embsi-1, embsi)

new_len = current_len + len(sentencesi)

判断是否合并

if sim > 0.7 and new_len < self.max_size:

current_chunk.append(sentencesi)

current_len = new_len

else:

如果当前chunk太小,强制合并

if current_len < self.min_size and i < len(sentences) - 1:

current_chunk.append(sentencesi)

current_len = new_len

else:

chunks.append(''.join(current_chunk))

current_chunk = sentences\[i]

current_len = len(sentencesi)

if current_chunk:

chunks.append(''.join(current_chunk))

return chunks

def cosine_similarity(self, a, b):

return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

```

**父子结构**:

```python

def build_parent_child(chunks: ListDict) -> ListDict:

"""

将相邻的小chunk合并为parent,保留child用于检索

"""

result = \[\]

i = 0

while i < len(chunks):

chunk = chunksi

如果chunk已经是parent,直接保留

if chunk'type' == 'parent':

result.append({

'id': f"parent_{i}",

'content': chunk'content',

'is_parent': True,

'children': \[\]

})

i += 1

continue

收集连续的child,合并成一个parent

children = \[\]

current_parent_content = \[\]

while i < len(chunks) and len(''.join(current_parent_content)) < 1000:

children.append(chunksi)

current_parent_content.append(chunksi'content')

i += 1

parent_content = ''.join(current_parent_content)

parent_id = f"parent_{i}"

为每个child建立到parent的关联

for child in children:

result.append({

'id': f"child_{child'id'}",

'content': child'content',

'is_parent': False,

'parent_id': parent_id

})

parent单独存储

result.append({

'id': parent_id,

'content': parent_content,

'is_parent': True,

'children': c\['id' for c in children]

})

return result

```

2.4 切分参数调优

在500条测试集上做了参数搜索:

| min_size | max_size | overlap | Hit@5 |

|----------|----------|---------|-------|

| 100 | 500 | 50 | 0.82 |

| 150 | 600 | 50 | 0.84 |

| 200 | 800 | 50 | 0.85 |

| 250 | 1000 | 50 | 0.83 |

| 200 | 800 | 80 | 0.85 |

| 200 | 800 | 30 | 0.84 |

最终选择:min_size=200, max_size=800, overlap=50。这个组合在召回率上最优,且chunk大小适合后续rerank(不超过512 token)。

三、去重与质量过滤

3.1 近似去重

企业文档中有大量重复内容(同一文档在不同部门重复存储)。用MinHash做近似去重:

```python

from datasketch import MinHash, MinHashLSH

def deduplicate_chunks(chunks: Liststr, threshold=0.85) -> Liststr:

lsh = MinHashLSH(threshold=threshold, num_perm=128)

构建MinHash

for i, chunk in enumerate(chunks):

m = MinHash(num_perm=128)

for word in chunk.split():

m.update(word.encode('utf8'))

lsh.insert(f"doc_{i}", m)

去重:保留第一个,删除相似的

unique = \[\]

seen = set()

for i, chunk in enumerate(chunks):

m = MinHash(num_perm=128)

for word in chunk.split():

m.update(word.encode('utf8'))

查询相似文档

similar = lsh.query(m)

if not similar:

unique.append(chunk)

lsh.insert(f"doc_{i}", m)

return unique

```

阈值设0.85,即相似度85%以上的视为重复。在12万份文档上去重后,chunk数量从8100万降到7300万,减少了约10%。

3.2 质量过滤

过滤低质量chunk:

```python

def filter_quality(chunk: str) -> bool:

1. 长度过滤

if len(chunk) < 50:

return False

2. 字符比例:中文/英文/数字占比低于30%的过滤

ascii_count = sum(1 for c in chunk if ord(c) < 128)

if ascii_count / len(chunk) > 0.9:

return False # 纯乱码或纯符号

3. 重复字符比例

from collections import Counter

counter = Counter(chunk)

max_freq = max(counter.values())

if max_freq / len(chunk) > 0.5:

return False # 大量重复字符

4. 是否包含有意义的中文词

import jieba

words = jieba.lcut(chunk)

content_words = w for w in words if len(w) \> 1

if len(content_words) < 3:

return False

return True

```

这个过滤器过滤掉了约8%的低质量chunk,主要是扫描PDF里的页眉页脚、空白页、乱码页。

四、完整的Pipeline代码

```python

class DocumentProcessor:

def init(self, config: dict):

self.config = config

self.chunker = SemanticChunker(

min_size=config.get('min_chunk_size', 200),

max_size=config.get('max_chunk_size', 800)

)

def process(self, file_path: str) -> ListDict:

1. 识别格式

ext = file_path.split('.')-1.lower()

2. 解析

if ext == 'pdf':

raw = self._parse_pdf(file_path)

elif ext == 'docx':

raw = self._parse_docx(file_path)

elif ext in 'xlsx', 'xls':

raw = self._parse_excel(file_path)

elif ext == 'pptx':

raw = self._parse_pptx(file_path)

else:

raw = self._parse_text(file_path)

3. 转Markdown

md_content = self._to_markdown(raw, ext)

4. 语义切分

chunks = self.chunker.split(md_content)

5. 父子结构

final = build_parent_child(chunks)

6. 质量过滤

final = c for c in final if filter_quality(c\['content')]

return final

def _parse_pdf(self, path: str) -> dict:

判断是否扫描版

if self._is_scanned_pdf(path):

return self._ocr_pdf(path)

else:

return self._parse_text_pdf(path)

def _is_scanned_pdf(self, path: str) -> bool:

用pdfplumber快速判断:提取不到文字或文字极少

with pdfplumber.open(path) as pdf:

page = pdf.pages0

text = page.extract_text()

return not text or len(text.strip()) < 50

```

五、数据统计

处理8000万chunk后的数据分布:

| 指标 | 数值 |

|------|------|

| 总文档数 | 12.4万 |

| 总chunk数(去重后) | 7300万 |

| 平均chunk长度 | 486字符 |

| 平均文档chunk数 | 588 |

| 数据处理总耗时 | 8天(6台GPU机器) |

| 过滤掉的无效chunk | 8.2% |