首页 / 文章

面向银行的RAG数据采集:PDF、OCR、表格及连接器

如何为RAG导入银行文档:对PDF进行分类,使用带审核标记的OCR工具处理,保留Word文档结构,妥善处理表格与图片,并构建可靠的连接器。

发布者:REACTAPP.TOP阅读约 18 分钟4126 词

来源: Data Ingestion for Production RAG: Building Reliable Enterprise Data Pipelines

一家银行的RAG系统悄然失灵

想象一下,某家中型银行在其内部知识库之上构建了一个检索增强生成系统。该系统的覆盖范围包括数千份文档:印度储备银行的监管通告、政策手册、《巴塞尔协议III》的相关指导文件、贷款处理流程、客户身份核实记录以及操作指南。这个项目起初进展顺利——一名工程师负责搭建向量数据库,另一名工程师编写检索流程,还有一名工程师连接大型语言模型并构建用户界面,随后系统便投入使用了。

三周后,用户开始抱怨答案有误,而原因并非幻觉。检索系统找到了正确的文档,模型也依据所给信息准确作答。问题其实早在更早的时候就出现了。一份关于非银行金融机构流动性标准的RBI通告仅以扫描版PDF形式存在,在OCR处理过程中,“10%”这个数字被错识别为“lO%”,其中的数字1被换成了小写的L。解析器未加检查就接受了这一错误数据。嵌入模型对有误的文本进行了编码,向量存储系统将其保存下来,检索系统又将其排在靠前的位置,最终大语言模型自信地告诉合规官员所需的比例是“lO percent.”

检索没有失败,生成也没有失败。每个组件都发挥了其设计功能。问题出在流程的第一阶段——数据导入,而这正是生产环境中的RAG系统中最常见且代价最高的隐患之一。团队们花费数周时间调整嵌入模型、分块方式、重排算法以及提示词,却将数据导入视为普通的预处理步骤。实际上,在任何查询到来之前,检索质量就已经决定了。如果源数据被错误解析,再好的嵌入模型、索引或大语言模型也无法还原原始事实。

接下来的内容将介绍企业级RAG系统中的数据导入处理:如何处理结构化、半结构化和非结构化数据,如何应对多模态文档,如何实现大规模扩展,如何识别解析错误,以及如何运用Python将这些功能整合起来。

数据导入的作用

在RAG流程中,数据摄取环节会接收各种格式和来源的原始文档,并将其转换为结构化、整洁的文本,以便后续进行分块处理和嵌入使用。

处理流程完全在离线管道中运行,早在任何用户提问之前就已开始。该管道输出经过解析和清洗的文本以及元数据,这些内容将成为后续所有步骤的输入。这里会进行三项处理:加载功能从存储位置获取原始文档,这些位置可能是文件系统、S3存储桶、SharePoint站点、REST API或数据库。解析功能则从原始格式中提取纯净文本,方式包括解码PDF、去除HTML标签、反序列化JSON或对图像进行OCR识别。初始元数据提取则会记录文档来源、创建日期、文档类型以及解析时能看到的所有与格式相关的细节。之后再进行文本清洗、分块和嵌入处理。整个流程的唯一目标就是从原始数据中提取出纯净的文本。

银行文档的复杂现实

银行的知识库并非以整洁的 Markdown 格式呈现,而是由数十年来积累的各种格式组成:20世纪90年代的扫描PDF文件、带有修订痕迹的Word文档、内部合规门户的HTML页面、政策管理系统输出的JSON数据、带有附件的电子邮件,以及结构复杂的SharePoint文件夹。每种格式都需要对应的解析器,而每个解析器又会有各自的故障表现。更糟糕的是,解析失败往往毫无征兆——无法正确解析的解析器不会抛出异常,只会返回乱码并继续处理下一项内容。

PDF文件:三种类型,三种处理方式

PDF在监管和合规文档中占据主导地位,同时也是最难解析的格式。针对三种不同的PDF类型,需要采用不同的处理方法:

  • 文本层PDF来自文字处理软件或打印转PDF功能,其文本内容位于内容流中,可直接提取。
  • 扫描版PDF是由实体纸张转换而成的图像文件,其中完全没有文本层,因此必须使用OCR技术。较旧的通告和历史政策大多属于此类。
  • 混合版PDF则是经过扫描后添加了质量较差的OCR文本层的文件。这类文件风险最高,因为简单的解析工具会直接使用存在错误的文本层,并误以为一切正常。

首先需要区分这两类文件,下方的分类功能会检测PDF并告知其所属类型。

import fitz  # PyMuPDF
from pathlib import Path

def classify_pdf(pdf_path: str) -> str:
    """
    Classify a PDF as text-native, scanned, or hybrid.
    Returns: 'text', 'scanned', or 'hybrid'
    """
    doc = fitz.open(pdf_path)
    total_pages = len(doc)
    pages_with_text = 0
    total_text_length = 0
    for page in doc:
        text = page.get_text("text").strip()
        if text:
            pages_with_text += 1
            total_text_length += len(text)
    doc.close()
    # No text layer at all: scanned PDF
    if pages_with_text == 0:
        return "scanned"
    # Text exists but average is suspiciously short: likely hybrid with poor OCR
    avg_text_per_page = total_text_length / total_pages
    if avg_text_per_page < 100 and pages_with_text < total_pages * 0.5:
        return "hybrid"
    return "text"

def load_text_pdf(pdf_path: str) -> list[dict]:
    """
    Extract text from a text-native PDF using PyMuPDF.
    Returns a list of page dicts with text and metadata.
    """
    doc = fitz.open(pdf_path)
    pages = []
    for page_num, page in enumerate(doc, start=1):
        text = page.get_text("text")
        pages.append({
            "page_number": page_num,
            "text": text.strip(),
            "source": pdf_path,
            "total_pages": len(doc),
        })
    doc.close()
    return pages

扫描后的文件需要OCR处理流程,该图表对比了三种实际可行的处理方案。

对于需要遵循RBI相关规定的银行而言,Azure Document Intelligence是更实用的选择。它能够处理质量参差的扫描文件,理解扫描页面中的表格结构,并为每个单词评估置信度,从而将置信度较低的提取结果标记出来以便人工审核。下方的加载器会调用该服务并应用相应的逻辑。

from azure.ai.formrecognizer import DocumentAnalysisClient
from azure.core.credentials import AzureKeyCredential

def load_scanned_pdf_azure(
    pdf_path: str,
    endpoint: str,
    api_key: str,
) -> list[dict]:
    """
    Extract text from a scanned PDF using Azure Document Intelligence.
    Returns pages with text, confidence scores, and table data.
    """
    client = DocumentAnalysisClient(
        endpoint=endpoint,
        credential=AzureKeyCredential(api_key),
    )
    with open(pdf_path, "rb") as f:
        poller = client.begin_analyze_document("prebuilt-read", f)
    result = poller.result()
    pages = []
    for page in result.pages:
        page_text = ""
        low_confidence_words = []
        for line in page.lines:
            page_text += line.content + "\n"
        # Flag words with confidence below threshold
        for word in page.words:
            if word.confidence < 0.85:
                low_confidence_words.append({
                    "word": word.content,
                    "confidence": word.confidence,
                })
        pages.append({
            "page_number": page.page_number,
            "text": page_text.strip(),
            "low_confidence_words": low_confidence_words,
            "needs_review": len(low_confidence_words) > 5,
            "source": pdf_path,
        })
    return pages

在受监管的环境中,needs_review 标志非常重要。如果一个页面包含超过五个置信度较低的单词,就不应被悄悄传递下去,而应放入人工审核队列中,或者至少在其元数据中添加相应标志,以便后续评估时能予以适当考虑。

Word文档、HTML及结构化数据源

Word文件常用于撰写内部政策与流程文档。它们包含标题、表格和列表,这些元素有助于后续检索,但在简单的文本提取工具下会丢失。此处的加载器会遍历文档并保留其结构。

from docx import Document as DocxDocument
from docx.oxml.ns import qn

def load_docx(docx_path: str) -> list[dict]:
    """
    Load a DOCX file preserving heading structure and tables.
    Returns a list of content blocks with type and text.
    """
    doc = DocxDocument(docx_path)
    blocks = []
    current_section = "Introduction"
    for element in doc.element.body:
        # Paragraphs
        if element.tag == qn("w:p"):
            para = element
            style = para.style.name if hasattr(para, "style") else ""
            text = para.text.strip()
            if not text:
                continue
            if style.startswith("Heading"):
                level = int(style.replace("Heading ", "")) if style != "Heading" else 1
                current_section = text
                blocks.append({
                    "type": "heading",
                    "level": level,
                    "text": text,
                    "section": current_section,
                    "source": docx_path,
                })
            else:
                blocks.append({
                    "type": "paragraph",
                    "text": text,
                    "section": current_section,
                    "source": docx_path,
                })
        # Tables
        elif element.tag == qn("w:tbl"):
            table_data = []
            for row in element.findall(f".//{qn('w:tr')}"):
                row_data = []
                for cell in row.findall(f".//{qn('w:tc')}"):
                    cell_text = " ".join(
                        p.text for p in cell.findall(f".//{qn('w:t')}")
                        if p.text
                    )
                    row_data.append(cell_text.strip())
                table_data.append(row_data)
            blocks.append({
                "type": "table",
                "data": table_data,
                "section": current_section,
                "source": docx_path,
            })
    return blocks

章节标记能带来显著效果。在包含四十个段落的KYC流程中,知道某部分内容来自“第3节:强化尽职调查”而非“第1节:标准KYC”,这样的元数据能大幅提升检索精度。

合规门户几乎都是网络应用,因此HTML加载时必须跳过导航、广告、Cookie提示栏以及实际内容周围的其他冗余元素。

import httpx
from bs4 import BeautifulSoup

def load_html_page(url: str, headers: dict | None = None) -> dict:
    """
    Load an HTML page and extract main content, stripping boilerplate.
    """
    response = httpx.get(url, headers=headers or {}, follow_redirects=True)
    response.raise_for_status()
    soup = BeautifulSoup(response.text, "html.parser")
    # Remove boilerplate elements common in compliance portals
    for tag in soup(["nav", "header", "footer", "script", "style",
                     "aside", "advertisement", ".cookie-banner",
                     ".sidebar", ".breadcrumb"]):
        tag.decompose()
    # Find main content area (adjust selectors for your portal)
    main_content = (
        soup.find("main")
        or soup.find("article")
        or soup.find(id="content")
        or soup.find(class_="content")
        or soup.find("body")
    )
    text = main_content.get_text(separator="\n", strip=True) if main_content else ""
    return {
        "url": url,
        "title": soup.title.string if soup.title else "",
        "text": text,
        "source": url,
    }

诸如政策管理系统、案例数据库和事务日志之类的结构化数据源需要不同的处理方式。这类数据的文本本身已经较为规整,因此关键问题在于确定应提取哪些字段,以及如何将数据行转换为可检索的文本形式。

import json
import psycopg2
from typing import Any

def load_json_policies(json_path: str, text_fields: list[str]) -> list[dict]:
    """
    Load a JSON array of policy documents, extracting specified text fields.
    Banking example: a JSON export from a policy management system.
    """
    with open(json_path, "r", encoding="utf-8") as f:
        policies = json.load(f)
    documents = []
    for policy in policies:
        # Combine relevant text fields into a single document
        text_parts = []
        for field in text_fields:
            if field in policy and policy[field]:
                text_parts.append(f"{field.replace('_', ' ').title()}: {policy[field]}")
        documents.append({
            "text": "\n\n".join(text_parts),
            "policy_id": policy.get("id"),
            "policy_type": policy.get("type"),
            "effective_date": policy.get("effective_date"),
            "jurisdiction": policy.get("jurisdiction"),
            "source": json_path,
        })
    return documents

def load_from_postgres(
    connection_string: str,
    query: str,
    text_column: str,
    metadata_columns: list[str],
) -> list[dict]:
    """
    Load documents from a PostgreSQL database.
    Banking example: regulatory requirement records from a compliance database.
    """
    conn = psycopg2.connect(connection_string)
    cursor = conn.cursor()
    cursor.execute(query)
    columns = [desc[0] for desc in cursor.description]
    rows = cursor.fetchall()
    cursor.close()
    conn.close()
    documents = []
    for row in rows:
        row_dict = dict(zip(columns, row))
        documents.append({
            "text": str(row_dict.get(text_column, "")),
            **{col: row_dict.get(col) for col in metadata_columns},
            "source": "postgresql",
        })
    return documents

银行将大多数工作文件存储在SharePoint或Google Workspace中,要访问这些文件需要OAuth以及相应的Graph或Drive API。

import httpx
from typing import Generator

def load_sharepoint_library(
    tenant_id: str,
    client_id: str,
    client_secret: str,
    site_id: str,
    drive_id: str,
) -> Generator[dict, None, None]:
    """
    Iterate over all DOCX and PDF files in a SharePoint document library.
    Yields file metadata dicts; caller is responsible for downloading and parsing.
    """
    # Get access token
    token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
    token_response = httpx.post(token_url, data={
        "grant_type": "client_credentials",
        "client_id": client_id,
        "client_secret": client_secret,
        "scope": "https://graph.microsoft.com/.default",
    })
    access_token = token_response.json()["access_token"]
    headers = {"Authorization": f"Bearer {access_token}"}
    base_url = f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives/{drive_id}"
    # List files recursively
    next_link = f"{base_url}/root/children"
    while next_link:
        response = httpx.get(next_link, headers=headers)
        data = response.json()
        for item in data.get("value", []):
            if item.get("file") and item["name"].endswith((".pdf", ".docx")):
                yield {
                    "name": item["name"],
                    "download_url": item.get("@microsoft.graph.downloadUrl"),
                    "created": item.get("createdDateTime"),
                    "modified": item.get("lastModifiedDateTime"),
                    "size": item.get("size"),
                    "web_url": item.get("webUrl"),
                }
        next_link = data.get("@odata.nextLink")

批量处理还是流式处理

每一种数据导入设计都始于一个决策:是在文档到达时立即处理,还是按预定时间批量处理?

  • 批量导入按照预定时间表执行,通常是夜间或每周一次。当数据的时效性不是关键因素、数据量很大且成本需要控制时,这种方式很合适。例如,银行每月导入印度储备银行的通告时并不需要实时处理,而夜间执行任务成本更低。
  • 流式导入则是在文档到达时通过事件队列进行处理。当数据的时效性至关重要时,比如需要在一个新通告发布后几分钟内就将其显示出来的合规系统,这种方式更为适用。

外部连接器需要具备防止速率限制的能力,下面的类通过重试和延迟机制来处理请求。

import time
import httpx
from pathlib import Path

class RateLimitedConnector:
    """
    A base connector with retry logic and rate limiting.
    Use this pattern for any external API that imposes rate limits.
    Banking example: connecting to an RBI document portal or a Bloomberg data feed.
    """
    def __init__(
        self,
        base_url: str,
        api_key: str,
        requests_per_minute: int = 60,
        max_retries: int = 3,
        backoff_factor: float = 2.0,
    ):
        self.base_url = base_url
        self.api_key = api_key
        self.min_interval = 60.0 / requests_per_minute
        self.last_request_time = 0.0
        self.max_retries = max_retries
        self.backoff_factor = backoff_factor
    def _wait_for_rate_limit(self):
        elapsed = time.monotonic() - self.last_request_time
        if elapsed < self.min_interval:
            time.sleep(self.min_interval - elapsed)
        self.last_request_time = time.monotonic()
    def fetch(self, endpoint: str, params: dict | None = None) -> dict:
        url = f"{self.base_url}/{endpoint}"
        headers = {"Authorization": f"Bearer {self.api_key}"}
        for attempt in range(self.max_retries):
            self._wait_for_rate_limit()
            try:
                response = httpx.get(url, headers=headers, params=params or {})
                if response.status_code == 429:
                    # Rate limited: back off and retry
                    wait_time = self.backoff_factor ** attempt
                    time.sleep(wait_time)
                    continue
                response.raise_for_status()
                return response.json()
            except httpx.HTTPError as e:
                if attempt == self.max_retries - 1:
                    raise
                time.sleep(self.backoff_factor ** attempt)
        raise RuntimeError(f"Failed to fetch {url} after {self.max_retries} attempts")

那种模式并非可选。包括金融数据提供商在内的大多数外部 API,一旦超出限制就会封锁该 IP 或撤销密钥。backoff_factor参数实现了指数级退避机制,即连续重试时的等待时间分别为2秒、4秒和8秒,这是生产环境连接器最基本的重试策略。

表格、图表与图片

2025年,生产环境中的RAG技术已从仅处理文本的流程转向多模态处理。银行文件并非纯文本形式,其中包含资本充足率表格、不良贷款趋势图表、客户身份核实流程图以及已签署协议的图片。忽略非文本内容会丢弃每份文件中大量信息,而在监管要求下,这些信息往往是最有价值的。印度储备银行通告中所列的精确数值阈值,正是合规团队所需要的内容。

表格有两种处理方式:以文本形式序列化,或提取为结构化数据。具体选择取决于后续用途。下面的提取函数可用于从PDF中获取表格。

import fitz
import pdfplumber

def extract_tables_from_pdf(pdf_path: str) -> list[dict]:
    """
    Extract tables from a PDF using pdfplumber.
    Returns tables as both structured data and serialised text.
    Banking example: extracting capital ratio tables from Basel III compliance reports.
    """
    tables_output = []
    with pdfplumber.open(pdf_path) as pdf:
        for page_num, page in enumerate(pdf.pages, start=1):
            tables = page.extract_tables()
            for table_idx, table in enumerate(tables):
                if not table or len(table) < 2:
                    continue
                # First row as headers
                headers = [str(h).strip() if h else f"col_{i}"
                           for i, h in enumerate(table[0])]
                rows = table[1:]
                # Structured data representation
                structured = []
                for row in rows:
                    row_dict = {}
                    for header, cell in zip(headers, row):
                        row_dict[header] = str(cell).strip() if cell else ""
                    structured.append(row_dict)
                # Text serialisation (markdown table format for LLM consumption)
                header_row = " | ".join(headers)
                separator = " | ".join(["---"] * len(headers))
                data_rows = [
                    " | ".join(str(cell or "").strip() for cell in row)
                    for row in rows
                ]
                serialised = "\n".join([header_row, separator] + data_rows)
                tables_output.append({
                    "page_number": page_num,
                    "table_index": table_idx,
                    "headers": headers,
                    "structured_data": structured,
                    "serialised_text": serialised,
                    "source": pdf_path,
                })
    return tables_output

序列化后的 Markdown 适合让大型语言模型阅读并进行分析,比如“巴塞尔协议 III 要求的 CET1 比率是多少?”。结构化的 JSON 则适合在代码中查询或进行数值比较的场景。

图表、示意图和信息图同样不可或缺。按季度划分的不良贷款趋势图,或是 FEMA 交易审批流程的流程图,都包含着周围文字中找不到的信息。常见的 2025 年处理方式是将每张图片输入视觉语言模型,然后存储生成的描述以便后续嵌入和检索。

import fitz
import base64
import httpx
from io import BytesIO

def extract_and_describe_images(
    pdf_path: str,
    openai_api_key: str,
    min_image_size: int = 5000,  # bytes, to skip tiny decorative images
) -> list[dict]:
    """
    Extract images from a PDF and generate text descriptions using GPT-4V.
    Banking example: describing NPA trend charts and regulatory workflow diagrams.
    """
    doc = fitz.open(pdf_path)
    image_docs = []
    for page_num in range(len(doc)):
        page = doc[page_num]
        image_list = page.get_images(full=True)
        for img_idx, img_info in enumerate(image_list):
            xref = img_info[0]
            base_image = doc.extract_image(xref)
            image_bytes = base_image["image"]
            # Skip tiny decorative images
            if len(image_bytes) < min_image_size:
                continue
            # Encode to base64 for the vision API
            image_b64 = base64.b64encode(image_bytes).decode("utf-8")
            image_ext = base_image["ext"]
            # Generate description with GPT-4V
            response = httpx.post(
                "https://api.openai.com/v1/chat/completions",
                headers={"Authorization": f"Bearer {openai_api_key}"},
                json={
                    "model": "gpt-4o",
                    "messages": [
                        {
                            "role": "user",
                            "content": [
                                {
                                    "type": "text",
                                    "text": (
                                        "This image is from a banking regulatory document. "
                                        "Describe its content precisely, including any numerical "
                                        "values, trends, labels, axes, or process steps shown. "
                                        "If it is a chart, describe what metric is shown, the "
                                        "time period, and the key data points. If it is a "
                                        "diagram, describe the process or relationship shown."
                                    ),
                                },
                                {
                                    "type": "image_url",
                                    "image_url": {
                                        "url": f"data:image/{image_ext};base64,{image_b64}"
                                    },
                                },
                            ],
                        }
                    ],
                    "max_tokens": 400,
                },
            )
            description = response.json()["choices"][0]["message"]["content"]
            image_docs.append({
                "type": "image_description",
                "page_number": page_num + 1,
                "image_index": img_idx,
                "description": description,
                "source": pdf_path,
            })
    doc.close()
    return image_docs

提示词在这里至关重要。泛泛的“描述这张图片”只会得到千篇一律的结果;而针对特定领域的指令,要求提供数值、趋势、标签或流程步骤,才能生成有实际参考价值的描述。例如,工程师若想了解第三季度报告中的NPA趋势线,就需要包含“NPA”“趋势”以及相应季度的描述,而非关于蓝色柱状图的描述。

音频与视频

音频和视频是当前银行领域RAG技术的前沿应用方向。董事会会议记录、分析师电话会议文字稿、印度储备银行新闻发布会内容以及培训视频中都包含着RAG系统目前还无法获取的宝贵信息。

import httpx
import time

def transcribe_audio(
    audio_path: str,
    openai_api_key: str,
    language: str = "en",
) -> dict:
    """
    Transcribe an audio file using OpenAI Whisper.
    Banking example: transcribing an RBI monetary policy press conference recording.
    """
    with open(audio_path, "rb") as audio_file:
        response = httpx.post(
            "https://api.openai.com/v1/audio/transcriptions",
            headers={"Authorization": f"Bearer {openai_api_key}"},
            data={
                "model": "whisper-1",
                "language": language,
                "response_format": "verbose_json",  # includes timestamps per segment
                "timestamp_granularities[]": "segment",
            },
            files={"file": (audio_path, audio_file, "audio/mpeg")},
        )
    result = response.json()
    return {
        "transcript": result.get("text", ""),
        "segments": [
            {
                "start": seg["start"],
                "end": seg["end"],
                "text": seg["text"],
            }
            for seg in result.get("segments", [])
        ],
        "language": result.get("language", language),
        "source": audio_path,
    }

输出中的时间戳非常重要。当问题的答案出现在电话会议进行到34分钟时的某段对话中时,系统应标明确切的位置,而不仅仅是引用会议录音。

每种格式对应一个路由器

单独的加载器是不够的。生产流程必须将每份文档发送到正确的解析器,妥善处理故障,跟踪处理状态,并无论输入内容如何都能生成一致的输出格式。

from pathlib import Path
from enum import Enum
import logging

logger = logging.getLogger(__name__)

class DocumentType(Enum):
    PDF_TEXT = "pdf_text"
    PDF_SCANNED = "pdf_scanned"
    DOCX = "docx"
    HTML = "html"
    JSON = "json"
    AUDIO = "audio"
    UNKNOWN = "unknown"

def detect_document_type(file_path: str) -> DocumentType:
    path = Path(file_path)
    suffix = path.suffix.lower()
    if suffix == ".pdf":
        classification = classify_pdf(file_path)
        return DocumentType.PDF_SCANNED if classification == "scanned" else DocumentType.PDF_TEXT
    elif suffix == ".docx":
        return DocumentType.DOCX
    elif suffix in (".html", ".htm"):
        return DocumentType.HTML
    elif suffix == ".json":
        return DocumentType.JSON
    elif suffix in (".mp3", ".mp4", ".wav", ".m4a"):
        return DocumentType.AUDIO
    return DocumentType.UNKNOWN

def ingest_document(
    file_path: str,
    azure_endpoint: str | None = None,
    azure_api_key: str | None = None,
    openai_api_key: str | None = None,
) -> list[dict]:
    """
    Route a document to the correct parser and return a list of content blocks
    with a consistent schema regardless of input format.
    Every output block contains:
    - text: the extracted text content
    - source: origin path or URL
    - doc_type: the detected document type
    - page_number: where applicable
    - needs_review: flag for human review queue
    """
    doc_type = detect_document_type(file_path)
    results = []
    try:
        if doc_type == DocumentType.PDF_TEXT:
            pages = load_text_pdf(file_path)
            for page in pages:
                page["doc_type"] = "pdf_text"
                page["needs_review"] = False
                results.append(page)
        elif doc_type == DocumentType.PDF_SCANNED:
            if azure_endpoint and azure_api_key:
                pages = load_scanned_pdf_azure(file_path, azure_endpoint, azure_api_key)
                for page in pages:
                    page["doc_type"] = "pdf_scanned"
                    results.append(page)
            else:
                logger.warning(
                    f"Scanned PDF detected but no OCR credentials provided: {file_path}"
                )
                results.append({
                    "text": "",
                    "source": file_path,
                    "doc_type": "pdf_scanned",
                    "needs_review": True,
                    "error": "No OCR credentials available",
                })
        elif doc_type == DocumentType.DOCX:
            blocks = load_docx(file_path)
            for block in blocks:
                block["doc_type"] = "docx"
                block["needs_review"] = False
                results.append(block)
        elif doc_type == DocumentType.AUDIO:
            if openai_api_key:
                transcript = transcribe_audio(file_path, openai_api_key)
                for segment in transcript["segments"]:
                    results.append({
                        "text": segment["text"],
                        "source": file_path,
                        "doc_type": "audio_transcript",
                        "start_seconds": segment["start"],
                        "end_seconds": segment["end"],
                        "needs_review": False,
                    })
        else:
            logger.warning(f"Unknown document type, skipping: {file_path}")
    except Exception as e:
        logger.error(f"Ingestion failed for {file_path}: {e}")
        results.append({
            "text": "",
            "source": file_path,
            "doc_type": str(doc_type.value),
            "needs_review": True,
            "error": str(e),
        })
    return results

统一的输出结构是最重要的设计决策。无论输入的是扫描后的圆形文档、DOCX格式的贷款政策文件,还是合规培训课程的音频转录内容,分块器、嵌入生成器和元数据增强器都会接收相同格式的词典。needs_review标志会将存在问题的文档放入人工处理队列,从而防止错误进一步扩散。

大多数问题的根源在于三种故障

银行领域RAG系统在检索质量方面出现的问题,大多可追溯到三种数据导入错误。

解析时忽略文档类型。 对扫描后的 PDF 进行文本提取时,往往不会得到任何结果,或仅有少量杂乱字符。这样的文件会变成空片段、接近零的向量值以及检索系统返回的空索引条目。大语言模型看到空上下文后就会编造答案。classify_pdf 函数仅需几行代码就能避免这一问题,省去数周的调试时间。

将表格视为正文文本处理。 被转换成普通文字的风险评级表会失去行和列结构,像“AAA 0.01% AA 0.05% A 0.10%”这样的字符串也无法被有效检索。但如果以带表头的 Markdown 表格形式序列化,就能实现有效检索。应单独提取表格并保留其结构。

连接器缺少重试机制。当 SharePoint 连接器遇到网络超时却没有任何反馈时,数据集中就会出现缺失,而且没人知道哪些文档不见了。RateLimitedConnector模式会在重试次数用尽时触发,并记录每次尝试的情况,这样缺失部分就能被识别出来。

工具的权衡

  • PyMuPDF、pdfplumber 和 PyPDF2。PyMuPDF 是处理纯文本 PDF 的最快提取工具。pdfplumber 速度稍慢,但在处理表格方面表现更好。PyPDF2 是最老的版本,准确性也最低,因此在新流程中应避免使用。对于同一文件,可用 PyMuPDF 提取文本,用 pdfplumber 提取表格。
  • 自托管或云端 OCR。Tesseract 是免费的,可在本地运行,对清晰度较高的扫描件处理效果相当不错。但对于成千上万质量参差不齐的历史公告而言,Azure Document Intelligence 更高的准确率使其每页的收费变得合理。误判监管阈值所带来的后果绝非值得节省成本去承担的。
  • 流式处理成本。实时流式处理每上传的文件是最昂贵的架构,而每天只需添加两三份文件的银行并无此必要。这种方案仅适用于那些文件时效性会直接影响业务结果的场景,比如交易部门需要即时获取印度证券交易委员会的新公告。人力资源政策库则不属于此类场景。
  • 多模态成本。若将每份文档中的所有图片都通过GPT-4o处理,大规模应用时会产生实际费用。应过滤掉那些小于5KB的装饰性小图片,剔除那些因宽高比而被识别为标志或图标的图片,并对描述信息进行缓存,这样同一张图片在多份文档中出现时只需描述一次。
  • 原始文本并非干净文本

    数据导入后得到的是原始文本,而非干净文本。在数据导入与分块处理之间还有一个独立的清洗阶段,负责去除噪声、去重、修正OCR错误、实现多语言标准化以及删除冗余内容。分阶段的处理很重要,因为不同环节会出现不同的问题:数据导入失败会导致内容为空或混乱,而清洗失败则会使内容表面上看似正常,但实际上存在会影响嵌入质量的细微噪声,这类问题只有在执行检索测试时才会显现出来。

    在正式生产环境中,代码示例需要具备日志记录功能、密钥管理机制(应将API密钥存储在环境变量或专用密钥管理系统中,绝不能硬编码),并在每个处理阶段添加延迟监测功能。