RAG 数据摄取流水线

RAG Data Ingestion Pipeline

引言

检索增强生成(Retrieval Augmented Generation,RAG)系统依赖外部文档知识库为大语言模型的回答提供依据。本课讨论如何组织适合检索的知识库,并介绍 RAG 流水线所需的数据准备与预处理工作:清理原始数据,剔除模板文字、导航链接等噪声;将文档切分为有用的文本块;为文本块建立索引,以支持高效的语义搜索。

为什么数据准备对 RAG 至关重要?

在建立索引前,必须先清理文档并规整格式,删去模板文字、冗余元数据、页眉页脚及其他低价值信息。数据清理的质量直接影响检索效果:语料越干净、主题越集中,检索器就越容易将用户问题与相关信息准确匹配。提取结果中的广告、导航链接与模板文字,均应在预处理阶段剔除。

RAG 数据摄取流水线

图 1 · 资料摄取与完整性检查
在线或本地资料
飞书 OpenAPI / PDF / DOCX
按类型读取
权限、分页、页码和表格
完整性检查
遗漏、OCR、图片与复杂结构
分块与 embedding
来源 ID、hash 与向量维度
授权知识索引
暂存、更新、删除与撤权
摄取报告
失败资料保持不可查询
在线或本地资料 → 按类型读取 → 完整性检查 → 分块与 embedding → 授权知识索引 → 摄取报告

本课重点介绍前两个核心步骤:数据摄取与数据清理。检索系统处理的文档通常分为结构化、半结构化和非结构化三类,理解三者的差异是有效处理数据的前提:

1. 结构化文档

定义:按照预定义格式组织的数据,通常采用表格形式,便于直接查询和处理。

示例:

  • 数据库(如 SQL 表)
  • 电子表格(如 Excel 文件、CSV 文件)
  • 具有明确 schema 定义的 JSON 和 XML 文件

2. 半结构化文档

定义:包含部分结构化元素,但没有严格组织形式或完整 schema 的文档。

示例:

  • HTML 网页
  • 没有严格 schema 的 JSON 文档
  • 结构可变的 XML 文档

3. 非结构化文档

定义:没有预定义结构的数据,通常难以直接自动处理,需要借助更深入的提取方法。

示例:

  • PDF 文档(如研究论文和报告)
  • DOCX 文件(如信件和文章)
  • 需要 OCR 的图像和扫描文档
  • 电子邮件和纯文本文档

文档类型直接决定了 RAG 系统处理多模态数据的方式。结构化文档通常更容易直接执行 embedding 和检索;半结构化及非结构化文档往往需要提取文本、视觉或音频等多模态内容,并配合专门的处理手段(例如对图像执行光学字符识别 OCR、对音频进行语音转写、为非结构化文本生成语义 embedding)。准确识别文档类型,有助于选用合适的多模态 embedding 模型与解析工具,提高检索流水线的整体准确性与效果。

数据摄取

数据摄取是为检索系统准备数据的起点,涵盖收集、解析和组织来自不同来源的原始数据,并完成初步处理。其主要目标包括:

数据摄取的主要目标包括:

  • 高效收集多种格式、多个来源的原始数据。
  • 将收集到的数据整理为格式一致、便于下游处理的结构。
  • 可靠地处理异常与错误,保障数据完整性。

在工程实现上,批量摄取可使用自托管的 Apache Airflow 调度作业;持续事件流处理可采用 Kafka、Pulsar 与 Apache Flink。来源 API 的分页、消息保留策略、消费确认(ACK)、顺序控制与背压(Backpressure)需分别配置。批处理与流处理可按资料更新时效要求组合使用;若采用云平台提供的 ETL 或事件服务,则需按其实际能力另行接入。

下图展示了数据摄取的各个核心环节:

图 2 · 可靠的数据摄取
数据摄取结合内容解析、重试、幂等处理、容错、元数据和检查点,并由日志与监控反馈运行情况。

1. 解析与结构化

定义:将 PDF、HTML、图像、JSON 等原始数据源转换为有意义的结构化元素(如段落、表格、标题、文本块和元数据)。

推荐做法:

  • 保留来源 URL、时间戳、页码等关键元数据。
  • 保留标题、表格、段落和项目列表等语义结构。
  • 将不同格式的数据转换为统一的结构表示。

常见问题:

  • 仅提取纯文本,丢失原有的文档结构与上下文关系。
  • 未保留原始元数据,给后续检索过滤带来困难。

推荐工具:

  • LlamaParse:输出结构化 Markdown,适合处理表格。
  • Trafilatura:用于结构化 HTML 正文提取。
  • PyMuPDF(fitz):用于快速解析 PDF。
  • Grobid:用于解析学术和科学论文 PDF。

2. 重试机制

定义:当数据获取任务因网络波动或数据源暂时不可用等短暂故障失败时,自动执行重试。

推荐做法:

  • 采用指数退避(Exponential Backoff)策略,逐步拉长重试间隔。
  • 限制最大重试次数(通常设为 3~5 次),防止产生过高负载。
  • 清晰记录每次重试日志,便于掌握运行状态和排查问题。

常见问题:

  • 缺少重试逻辑,偶发的轻微故障即导致整条流水线中断。
  • 重试次数过多,导致上游数据源过载或摄取延迟显著增加。

3. 幂等处理

定义:同一摄取任务重复执行多次仍产生相同结果,防止写入重复数据。

推荐做法:

  • 使用 SHA-256、MD5 等算法基于内容计算哈希值,生成唯一标识符。
  • 每次处理文档前,先通过唯一哈希检查该文档是否已完成处理。

常见问题:

  • 仅依赖时间戳或来源系统生成的 ID,而这些标识可能发生变动或重复。
  • 完全忽视幂等性,导致索引中出现重复条目。

推荐工具:Python 内置的 hashlib。

Python
import hashlib

def generate_hash(content):
    return hashlib.sha256(content.encode('utf-8')).hexdigest()

4. 容错与错误处理(DLQ)

定义:隔离存在问题的异常数据并妥善处理摄取错误,使整条流水线能够继续处理其余正常数据。

推荐做法:

  • 配置死信队列(Dead-Letter Queue,DLQ),暂存问题文档供人工排查。
  • 定期监控 DLQ 中反复出现的错误模式,持续改进解析与摄取逻辑。

常见问题:

  • 未配置 DLQ,单条损坏数据即导致整条流水线停摆。
  • 没有定期检查 DLQ,错过持续改进的机会。

推荐工具:Kafka Streams、Redis Streams、简单的 Python 队列或数据库。

其基本逻辑可用以下 Python 代码表示:

Python
dead_letter_queue = []

try:
    process_document(document)
except Exception as e:
    dead_letter_queue.append({"document": document, "error": str(e)})

5. 元数据提取与检查点

定义:提取有价值的元数据,并持久化记录摄取进度(Checkpoint),以保障处理过程的连续性。

推荐做法:

  • 提取并保存来源 URL、文档时间戳、文档 ID 等核心元数据。
  • 定期保存摄取进度检查点,以便在发生故障或中断后从断点处恢复。

常见问题:

  • 未定期保存检查点,故障恢复后不得不重复处理已摄取的文档。
  • 元数据格式或口径不一致,影响下游检索的准确性。

推荐工具:本地检查点文件、Redis、PostgreSQL。

以下 Python 示例展示了一种简单的文件检查点实现:

Python
def save_checkpoint(last_document_id):
    with open("checkpoint.txt", "w") as f:
        f.write(last_document_id)

def load_checkpoint():
    try:
        with open("checkpoint.txt", "r") as f:
            return f.read().strip()
    except FileNotFoundError:
        return None

6. 日志与监控

定义:通过结构化日志记录流水线事件、错误和性能指标,并集中汇总以支持实时监控与告警。

推荐做法:

  • 采用 JSON 等结构化日志格式,记录时间戳、事件类型、文档 ID 和错误信息。
  • 使用 ELK Stack、Datadog、Splunk 等集中式日志管理系统汇总日志。
  • 针对严重故障和异常指标配置实时告警。

常见问题:

  • 日志缺乏结构化字段,增加了错误分析与监控的难度。
  • 日志分散在各节点未集中管理,难以跨组件关联摄取事件。

推荐工具:

  • Python 内置 logging
  • ELK Stack、Datadog、Splunk(集中式日志管理)
  • Prometheus、Grafana(监控与告警)

反馈循环

1. 日志与监控 → 重试机制

日志与监控 → 重试机制:当触发特定告警或错误率达到阈值时,由日志监控系统自动触发重试流程。

2. DLQ → 日志与监控

DLQ → 日志与监控:持续记录并监控 DLQ 入队事件,识别高频出现的摄取异常,及时触发人工介入或自动处理。

数据摄取的推荐做法

设计良好的摄取流水线兼顾可扩展性与可靠性,并从入口处保障数据质量:

选择合适的摄取模式:根据应用对延迟和数据量的要求,选择批量、流式或混合摄取。批量摄取适合周期性加载,结构相对简单、成本较低;流式摄取支持实时场景,但系统复杂度更高。将二者结合通常可以兼顾不同场景的需求。

尽早定义数据接口约定与 schema:像管理 API 契约一样管理数据接口,明确输入数据的字段、类型与格式要求。在摄取入口处通过 Avro、Protobuf 或 JSON Schema 执行校验,直接拒绝或标记不符合规范的记录,防止 schema 漂移破坏下游流水线。可借助 Confluent Schema Registry 等工具管理 schema 版本及兼容性。

保证幂等性并删除重复事件:分布式系统中不可避免会出现重试消息等重复数据。摄取过程应具备幂等性,使同一条记录即便被处理两次也不会产生副作用。尽早在入口处实现去重逻辑,例如为事件分配唯一 ID 并忽略重复项,或维护记录的内容哈希签名;在流式处理中,还可利用基于事件时间窗口的去重方法识别近似重复项,防止重复计数干扰统计指标与下游分析。

配置可靠的错误处理(Dead-Letter Queue):系统偶尔会收到损坏或格式异常的数据。流水线应设置结构化的错误处理机制,在遇到 JSON 无法解析或违反 schema 的记录时,将其连同错误信息转入 S3 bucket 或独立 Kafka topic 等形式的 DLQ,使正常数据得以继续摄取,同时保留问题记录供后续排查、修复或重放。

统一时间戳和时区:时间格式不统一是数据集成中的常见隐患。数据进入系统时,应将所有时间戳统一转换为 UTC 等标准时间(如有需要可额外记录摄取入库时间),采用 ISO 8601 等一致格式,并在条件允许时保留来源时区字段,防止合并跨地区或跨设备数据时发生时间错乱,支持准确的按时间聚合与跨系统关联。

6. 设置背压与有限重试

为摄取流水线设置背压与有限重试。利用 Kafka、RabbitMQ 或持久化任务表缓冲任务,在消费者完成处理后再确认(ACK)。每个来源使用幂等 ID;分页失败时停止发布当前批次,仅在完整快照成功后清理未出现的旧资料。横向扩展的工作进程仍需共同遵守来源 API 的共享限流配额。

实现可观测性与监控:缺乏可见性的问题难以排查。为摄取流水线配置完整的日志与指标,重点监控吞吐量(每秒事件数)、滞后时间(最新摄取数据距今的延迟)、错误率(失败记录占比)以及队列长度与积压量。使用 Logstash/Elasticsearch 或云日志服务集中记录事件详情;若流水线跨越多个微服务,可结合 OpenTelemetry 实现分布式追踪。通过 Prometheus/Grafana 或 Datadog 搭建监控面板,针对输入流量骤降、错误激增等异常配置告警,缩短故障排查与恢复时间。

规划 schema 演进:上游新增字段或调整格式在所难免。摄取链路应使用带版本号的 schema 并保持向后兼容(例如将新增字段设为可选),将 schema 版本随数据一同保存或托管在注册中心,使下游消费方明确如何解析每条记录。按预期版本校验输入数据,拒绝或转换确实不兼容的记录;结合 schema 注册中心的 Avro、Protobuf 等工具可通过兼容性模式防止上游变更引发流水线故障。

在摄取阶段保护数据:摄取层通常是敏感数据进入平台的第一道关口,应从入口处落实安全与隐私措施:所有数据传输(含内网传输)均启用 TLS 加密;验证数据源身份并执行权限校验;在入口处通过掩码(Masking)或分词替换(Tokenization)脱敏,避免落盘保存原始个人身份标识;完整记录数据访问与写入审计日志(包含发送方、发送时间与内容摘要),以便从源头满足 GDPR、HIPAA 等合规要求。

使用真实数据与边界情况测试:上线部署前,使用贴近生产环境的场景对摄取流水线做压力测试。回放经过匿名化处理的真实生产数据,检验流水线在实际事件分布与峰值流量下的表现;主动构造格式损坏、缺失必填字段、超大载荷、乱序事件等边界样本,并模拟下游服务中断以验证重试与背压逻辑。将这些测试纳入 CI/CD 流程,自动校验每次代码变更是否引入重复数据或破坏 schema 约束。

数据清理

完成数据摄取后,下一步是开展数据清理:修复或剔除错误、不一致与噪声,提升数据质量。研究表明,数据科学家的大部分时间都投入在包括清理与预处理在内的数据准备阶段——干净的数据直接决定了下游模型与分析结果的准确性(例如重复记录可能误导算法,异常值可能扭曲数据分布)。下图展示了通用的数据清理流程,具体步骤可随数据类型、规模及业务条件灵活调整:

图 3 · 清洗与质量检查流程
清洗流程结合文本、结构、OCR、敏感信息和跨模态检查,监控结果返回前面的质量处理步骤。

1. 初步质量评估

定义:尽早识别数据中的结构缺陷、噪声与低质量内容。

推荐做法:

  • 结合启发式规则检查与随机抽样审查。
  • 利用 LLM 执行快速自动化的初步质量评估。

常见问题:

  • 跳过或轻视早期质量检查。
  • 完全依赖人工抽检,导致处理延迟与人为疏漏。

推荐工具:自定义抽样脚本、基于 LLM 的自动评估。

2. 文本规范化

定义:统一并清理文本格式,提升可读性与一致性。

推荐做法:

  • 规范化空白字符、剔除无关 emoji、修复损坏的 Unicode 编码。
  • 在提升可读性的同时注意保留必要的语境信息。

常见问题:

  • 过度规范化(如盲目删去特殊符号或大小写)导致关键上下文丢失。
  • 忽略隐蔽的 Unicode 乱码或零宽字符问题。

推荐工具:regex、ftfy。

3. 结构整理

定义:剔除页眉、页脚、模板套话等无关结构元素。

推荐做法:

  • 保持标题层级、正文段落等核心文档结构的完整性。
  • 对不同数据源应用一致的结构清理规则。

常见问题:

  • 误删必要的上下文标题或说明,导致正文难以理解。
  • 各数据源的结构清理尺度不统一。

推荐工具:Trafilatura、BeautifulSoup。

4. OCR 噪声削减与纠正

定义:减少光学字符识别(OCR)错误并过滤低置信度文本,提升扫描件提取文本的质量。

推荐做法:

  • 结合识别置信度阈值过滤与拼写纠错算法。
  • 针对复杂的上下文断字或识别错误,结合 LLM 辅助纠错。

常见问题:

  • 放任高噪声、错字连篇的 OCR 文本直接进入数据集。
  • 过滤阈值过于严苛,误删有效正文。

推荐工具:SymSpell、LLM API。

5. 实体与敏感数据识别及掩码处理

定义:识别并遮蔽敏感个人身份信息(PII),保障隐私与合规。

推荐做法:

  • 定期更新命名实体检测与掩码脱敏规则。
  • 持续验证脱敏效果,防止敏感信息泄漏。

常见问题:

  • 漏检非标准格式的敏感信息。
  • 掩码处理过度,导致数据丢失。

推荐工具:spaCy、Presidio、regex。

6. 跨模态一致性

定义:确保正文中描述的文本内容与关联的图像、表格等多模态元素相互对应、保持一致。

推荐做法:

  • 利用 CLIP 等多模态 embedding 模型校验图文匹配度。
  • 定期抽检跨模态关联的准确性。

常见问题:

  • 忽略跨模态一致性检查。
  • 未经验证便默认解析出的图文或图表位置已自动对齐。

推荐工具:CLIP、启发式验证脚本。

7. 去重与过滤

定义:剔除重复或无关内容,维护数据集的纯净度与完整性。

推荐做法:

  • 结合字面精确匹配(哈希)与语义相似度去重方法。
  • 根据下游反馈循环持续调优过滤标准。

常见问题:

  • 仅依赖字面精确匹配,无法识别轻微改写的近似重复内容。
  • 对重复片段缺乏语义层面的比对分析。

推荐工具:hashlib、SentenceTransformers、LLM API。

8. 日志与监控

定义:全程追踪并记录数据清理过程,提升处理透明度与可靠性。

推荐做法:

  • 输出结构化日志,便于掌握清洗进度与排查问题。
  • 集中管理日志,支持实时监控与异常告警。

常见问题:

  • 日志格式混乱,难以用于自动化监控。
  • 日志分散存放,拉低排障效率。

推荐工具:Python logging、ELK Stack、Datadog。

反馈循环

日志与监控 → 初步质量评估:持续监控可将流水线错误或质量下滑信号迅速反馈至初步质量检查环节,动态调整准入标准。

日志与监控 → OCR 噪声削减:当监控发现 OCR 识别准确率明显下降时,及时反馈至 OCR 纠错环节调整参数。

日志与监控 → 结构整理:捕获并记录结构提取异常,反向推动解析工具与结构清理规则迭代。

飞书与钉钉资料摄取

主线任务通过飞书 Wiki 节点识别真实资源类型,分别读取新版文档(docx)、旧版文档(doc)与多维表格(bitable)。摄取时需将 source_id、block_id(或 record_id)、content_hash、更新时间、解析缺失项(omissions)及授权范围一并保存为摄取元数据。任一资料来源处理失败时,均保持“不可查询”状态;仅当完整快照拉取成功后,才执行已消失资料的删除清理。

examples/pipeline.pyPython
class FeishuReader:
    def __init__(self, client, app_id, app_secret, sleep=time.sleep, clock=time.monotonic):
        self.client = client
        self.app_id = app_id
        self.app_secret = app_secret
        self.sleep = sleep
        self.clock = clock
        self.token = None
        self.expires_at = 0

    def access_token(self):
        if self.token and self.clock() < self.expires_at:
            return self.token
        r = self.client.post('/open-apis/auth/v3/tenant_access_token/internal',
                             json={'app_id': self.app_id, 'app_secret': self.app_secret})
        r.raise_for_status()
        result = r.json()
        if result.get('code', 0) != 0:
            raise SourceError(f"tenant_token code={result['code']}")
        self.token = result['tenant_access_token']
        self.expires_at = self.clock() + max(0, result['expire'] - 60)
        return self.token

    def request(self, method, path, params=None, body=None):
        refreshed = False
        for attempt in range(5):
            r = self.client.request(method, path, params=params, json=body,
                                    headers={'Authorization': f'Bearer {self.access_token()}'})
            result = r.json() if 'json' in r.headers.get('content-type', '') else {}
            code = result.get('code', 0)
            if (r.status_code == 401 or code in {99991663, 99991668}) and not refreshed:
                self.token = None
                refreshed = True
                continue
            if r.status_code == 429 or code == 99991400 or r.status_code >= 500:
                if attempt == 4:
                    raise SourceError(f'retry_exhausted status={r.status_code} code={code}')
                retry_after = r.headers.get('Retry-After')
                delay = float(retry_after) if retry_after and retry_after.isdecimal() else 2 ** attempt + random.uniform(0, .2)
                self.sleep(min(delay, 30))
                continue
            r.raise_for_status()
            if code != 0:
                raise SourceError(f'feishu status={r.status_code} code={code}')
            return result['data']
        raise SourceError('token_refresh_exhausted')

    def pages(self, method, path, page_size, params=None, body=None):
        cursor = None
        seen = set()
        while True:
            query = dict(params or {}, page_size=page_size)
            if cursor:
                query['page_token'] = cursor
            data = self.request(method, path, query, body)
            yield from data.get('items', [])
            if not data.get('has_more', False):
                return
            cursor = data.get('page_token')
            if not cursor or cursor in seen:
                raise SourceError('invalid_page_token')
            seen.add(cursor)

    def wiki_nodes(self, space_id, parent=None):
        params = {'parent_node_token': parent} if parent else {}
        for node in self.pages('GET', f'/open-apis/wiki/v2/spaces/{token_path(space_id)}/nodes', 50, params):
            yield node
            if node.get('has_child'):
                yield from self.wiki_nodes(space_id, node['node_token'])

    def read_node(self, node, table_id=None):
        kind, token = node['obj_type'], token_path(node['obj_token'])
        if kind == 'docx':
            blocks = list(self.pages('GET', f'/open-apis/docx/v1/documents/{token}/blocks', 500))
            text, omissions = blocks_to_text(blocks)
            return text, omissions
        if kind in {'doc', 'document'}:
            data = self.request('GET', f'/open-apis/doc/v2/{token}/raw_content')
            return data['content'], ['旧版文档纯文本缺少表格结构与附件内容']
        if kind == 'bitable':
            if not table_id:
                raise SourceError('bitable_table_id_required')
            records = list(self.pages('POST', f'/open-apis/bitable/v1/apps/{token}/tables/{token_path(table_id)}/records/search',
                                      500, body={'automatic_fields': False}))
            return '\n'.join(json.dumps({'record_id': r['record_id'], 'fields': r['fields']}, ensure_ascii=False) for r in records), []
        raise SourceError(f'unsupported_obj_type={kind}')

飞书 docx、Wiki 与多维表格均调用各自的分页 API。遇到 HTTP 429、HTTP 400 中的业务限流码(99991400)以及临时性 5xx 错误时执行有限重试;遇到 403 权限拒绝、未知资源类型或重复分页游标时则直接终止当前批次。tenant_access_token 根据 expire 字段缓存,过期时自动刷新一次,日志仅记录状态码与错误类别。由于复杂的继承 ACL 需要独立权限服务判定,当无法确认权限时直接拒绝向量查询。

钉钉接口则采用不同的 token 字段、请求头与分页语义:Wiki 工作区每页最多返回 30 条,节点列表每页最多返回 50 条,通过 nextToken 翻页,且 operatorId 需传入用户的 unionId。文档块接口按 startIndex / endIndex 查询根节点下的范围后再遍历子块;AI 表格(Notable)则调用 records/list 接口读取。

examples/dingtalk.pyPython
import time
from urllib.parse import quote


class DingTalkReader:
    def __init__(self, client, app_key, app_secret, operator_union_id):
        self.client, self.app_key, self.app_secret = client, app_key, app_secret
        self.operator = operator_union_id
        self.token, self.expires_at = None, 0

    def request(self, method, path, params=None, body=None):
        if not self.token or time.monotonic() >= self.expires_at:
            response = self.client.post('/v1.0/oauth2/accessToken',
                                        json={'appKey': self.app_key, 'appSecret': self.app_secret})
            response.raise_for_status()
            data = response.json()
            self.token, self.expires_at = data['accessToken'], time.monotonic() + max(0, data['expireIn'] - 60)
        response = self.client.request(method, path, params=params, json=body,
            headers={'x-acs-dingtalk-access-token': self.token})
        response.raise_for_status()
        return response.json()

    def records(self, base_id, sheet_id):
        next_token, seen = None, set()
        while True:
            body = {'operatorId': self.operator, 'maxResults': 100}
            if next_token:
                body['nextToken'] = next_token
            data = self.request('POST', f'/v1.0/notable/bases/{quote(base_id, safe="")}/sheets/{quote(sheet_id, safe="")}/records/list', body=body)
            yield from data.get('records', [])
            next_token = data.get('nextToken')
            if not next_token:
                return
            if next_token in seen:
                raise ValueError('repeated_next_token')
            seen.add(next_token)

    def document_blocks(self, doc_key, start_index, end_index):
        if start_index < 0 or end_index <= start_index:
            raise ValueError('invalid_block_range')
        return self.request('GET', f'/v1.0/doc/suites/documents/{quote(doc_key, safe="")}/blocks',
            params={'operatorId': self.operator, 'startIndex': start_index, 'endIndex': end_index})

相关接口文档参见:钉钉内部应用 token、工作区、节点、文档块与 Notable 记录。读取 Notable 需开通相应读取权限,且单页 maxResults ≤ 100;其余具体调用配额需结合实际应用与接口文档核验。

消息通知与资料读取分离:自定义 webhook 机器人仅用于发送通知消息,知识库内容则通过文档 API 读取。删除事件、Wiki 更新与继承权限的覆盖范围需分别核验,仍需保留定时全量对账与权限撤回清理机制。

需要注意,托管解析服务 LlamaParse 会将文件上传至外部服务端,使用前应核查其服务地域、数据保留策略与使用权限。纯文本 PDF 可直接使用本地 PyMuPDF 解析,扫描件则需另行接入本地 OCR;涉及表格时需验证单元格提取、阅读顺序及引用位置是否准确。

Python
import pymupdf

with pymupdf.open("demo-policy.pdf") as document:
    pages = [{"page": i + 1, "text": page.get_text(sort=True)}
             for i, page in enumerate(document)]
print(pages)

练习:在演示文档中嵌入电子表格,观察 omissions 输出;实现对应的表格读取 API 后,对比记录 ID、表头与正文引用是否完整。

本地解析与来源完整性

随课下载包中的 local_parse.py 使用 PyMuPDF 和 python-docx 解析本地文件:PDF 解析保留页码与表格结构,DOCX 解析则按原始段落与表格顺序依次输出。

examples/local_parse.pyPython
import pymupdf
from docx import Document
from docx.table import Table
from docx.text.paragraph import Paragraph


def parse_pdf(path):
    pages = []
    with pymupdf.open(path) as document:
        if document.is_encrypted:
            raise PermissionError('encrypted_pdf_requires_authorized_password')
        for index, page in enumerate(document):
            text = page.get_text(sort=True)
            if not text.strip():
                raise ValueError(f'pdf_page_requires_ocr={index + 1}')
            tables = [table.extract() for table in page.find_tables().tables]
            pages.append({'page': index + 1, 'text': text, 'tables': tables})
    return pages


def parse_docx(path):
    document = Document(path)
    if document.inline_shapes:
        raise ValueError('docx_embedded_images_require_review')
    blocks = []
    for index, item in enumerate(document.iter_inner_content()):
        if isinstance(item, Paragraph):
            blocks.append({'block': index, 'type': 'paragraph', 'text': item.text})
        elif isinstance(item, Table):
            if any(cell.tables for row in item.rows for cell in row.cells):
                raise ValueError('docx_nested_tables_require_review')
            blocks.append({'block': index, 'type': 'table', 'rows': [[cell.text for cell in row.cells] for row in item.rows]})
    return blocks

上述示例面向普通文本与简单表格:遇到空白 PDF 页面时会抛出异常以便转入 OCR 处理队列,加密文件需取得授权密码后单独处理;DOCX 若包含内嵌图片或嵌套表格则会主动中断并要求人工核验,批注、脚注、文本框、修订记录及复杂合并单元格同样需要检查解析覆盖范围。代码解析成功仅代表本地提取步骤跑通,在将语料正式发布入库前,仍必须完成内容完整性、来源标识与访问权限核验。

对于扫描件资料,可另行接入本地 OCR 并明确记录所用模型、语种、版面分析方式与识别误差;若使用托管解析服务上传文件,则必须核查服务地域、数据保留策略与资料授权范围。本地文件解析使用上述本地脚本路径,而飞书在线资料仍统一通过对应文档类型的 OpenAPI 读取。

练习:使用本地合成的 PDF 与 DOCX 文件检查段落、表格和引用顺序;随后加入扫描页、内嵌图片与嵌套表格,确认系统会拦截缺失或待核验内容,不会将残缺资料直接发布为完整语料。

搜索课程

正在加载课程目录…