PDF多表格结构识别与跨表语义对齐:基于对抗迁移的鲁棒相似度度量模型

发布于:2025-03-28 ⋅ 阅读:(25) ⋅ 点赞:(0)


ocr扫描有其局限性。对于pdf文本类型这种pdfbox,aspose-pdf,spire直接提取文本的精准性更高。经过综合对比我们觉得aspose和spire在读取pdf文本方面较为优秀。基于此我们可能需要提取pdf中所有表格数据,完成数据录入。但是表格数据不同,还存在跨页表格问题。但是按照以下方案即可解决。本文的表格处理思想来源于mybatis的底层设计。

特征 余弦相似度 编辑距离
原理 衡量向量方向的夹角(语义相似性) 计算字符串转换所需的最小操作次数(字符级差异)
输入类型 向量(如文本的TF-IDF或词嵌入向量) 字符串或序列
关注点 语义层面的相似性(如主题、用词) 结构层面的差异(如拼写错误、字符顺序)
输出范围 [-1, 1](通常取绝对值或归一化为0-1) 非负整数(0表示完全匹配)
计算复杂度 O(n)(向量化后快速计算) O(n*m)(对长文本较慢)
典型应用 文档相似度、推荐系统、语义搜索 拼写纠错、DNA序列比对、短文本模糊匹配

开源地址

一. 项目结构

本设计基于aspose-pdf实现

|-- SpringContextUtil.java
`-- pdf
    |-- AbstractTextMappingTemplate.java #抽象模板映射器 解析内容映射到结构化对象
    |-- PDFboxTable.java # 暂留扩展
    |-- PdfTableParsingEngine.java # 表格解析引擎 提供从PDF文档中提取并处理表格数据的功能
    |-- StringEscapeUtil.java # 字符串转义工具类 防止注入攻击
    |-- TableBatchProcessor.java # 具体表格执行处理器 表格批处理器
    |-- annotation # 映射注解包
    |-- aspect # 注解处理器包
    |-- converter # 抽象模板映射器具体实现 包
    `-- entity # 想要映射的结构化对象包

二.流程分析

  1. 表格解析器提取pdf表格文本
  2. 表格批处理器负责具体执行表格解析
  3. 字符串转义避免恶意攻击
  4. 抽象映射器允许用户具体实现映射实体

2.1 批处理器核心代码解析

表格解析器每检测一页的所有表格,就提交到批处理器进行具体数据清洗,归一化。以下是进行数据批处理的核心逻辑

    /**
     * 添加表格到批处理队列
     *
     * @param pageIndex 页码索引
     * @param tables    页面中的表格列表
     */
    public void addPageTables(int pageIndex, List<AbsorbedTable> tables) {
        // 资源限制检查 一页10个表格
        if (tables.size() > MAX_TABLES_PER_PAGE) {
            log.warn("页面{}表格数量超过限制: {}", pageIndex, tables.size());
            // 截取前MAX_TABLES_PER_PAGE个表格
            tables = tables.subList(0, MAX_TABLES_PER_PAGE);
        }

        // 处理当前页的表格
        List<StringBuilder> processedTables = new ArrayList<>();
        for (AbsorbedTable table : tables) {
            // 处理单个表格
            StringBuilder tableContent = processSingleTable(table);
            if (tableContent == null) continue;

            // 数据清洗
            PdfTableParsingEngine.cleanData(tableContent);

            // 生成表格指纹
            String tableFingerprint = generateTableFingerprint(tableContent);

            // 将表格指纹和内容存储到跨页表格缓存中
            crossPageTableCache.putIfAbsent(tableFingerprint, new CacheEntry(new StringBuilder(tableContent)));
            // 更新缓存条目的最后访问时间
            crossPageTableCache.get(tableFingerprint).updateLastAccessTime();

            // 检查是否为跨页表格
            if (isCrossPageTable(tableFingerprint)) {
                // 合并跨页表格
                tableContent = mergeCrossPageTable(tableContent, tableFingerprint);
            } else {
                // 异常检测(连续重复表格)
                if (isDuplicateTable(tableFingerprint)) {
                    log.warn("检测到连续重复表格类型: {}", tableFingerprint);
                    continue;
                }
            }
            // 添加到处理队列
            processedTables.add(tableContent);
        }

        // 将处理后的表格添加到缓冲队列
        if (!processedTables.isEmpty()) {
            try {
                // 尝试添加到队列,如果队列已满则提交当前队列中的所有表格
                if (!tableBufferQueue.offer(processedTables, 100, TimeUnit.MILLISECONDS)) {
                    log.info("缓冲队列已满,提交批处理任务");
                    submitBatchTask();
                    // 重新尝试添加
                    tableBufferQueue.put(processedTables);
                }
            } catch (InterruptedException e) {
                log.error("添加表格到缓冲队列失败: {}", e.getMessage());
                Thread.currentThread().interrupt();
            }
        }
    }

如上述代码,

  • processSingleTable(AbsorbedTable table)用于具体解析表格内容并拼接成特定字符串。
  • cleanData(StringBuilder builder) 移除所有空白字符和换行符
  • generateTableFingerprint(StringBuilder tableContent) 用于识别跨页表格相似度合并
  • crossPageTableCache 缓存跨页表格,因为是以页为单位检测表格的。下一页需要保留上一页表格
  • mergeCrossPageTable(tableContent, tableFingerprint) 设定相似度大于85%且不为100%。为同一表格。进行合并。
  • submitBatchTask() 提交批处理任务
  • processBatchTables(List<List> batchTables) 获取抽象映射器的具体实现。根据具体规则进行映射匹配

三. 跨页表格相似度匹配原理

  • 1.根据特定表头内容相似度
  • 2.根据表格样式特征

3.1 表头内容相似度-特征向量归一化

字符串长度建议不要超过特征矩阵维度长度
使用余弦相似矩阵,比较两个表头字符串相似度.一般认为表头字串很短,因此初始化16特征向量即可
表示我们可以把字符ascii映射到特征向量上,并通过单位向量归一化结果。获取第一块内容字串的标准化特征向量。同理对第二块内容字串做标准化计算。

    /**
     * 计算内容相似度(基于矢量相似度)
     *
     * @param str1 字符串1
     * @param str2 字符串2
     * @return 内容相似度
     */
    private double calculateContentSimilarity(String str1, String str2) {
        if (str1 == null || str2 == null) {
            throw new IllegalArgumentException("输入字符串不能为空");
        }

        // 将字符串转换为特征向量
        double[] vector1 = stringToVector(str1);
        double[] vector2 = stringToVector(str2);

        // 计算余弦相似度
        return cosineSimilarity(vector1, vector2);
    }

    /**
     * 将字符串转换为特征向量
     *
     * @param str 输入字符串
     * @return 特征向量
     */
    private double[] stringToVector(String str) {
        // 初始化特征向量
        double[] vector = new double[VECTOR_DIMENSION];

        // 创建字符频率映射
        Map<Character, Integer> charFrequency = new HashMap<>();

        // 统计字符频率
        for (char c : str.toCharArray()) {
            charFrequency.put(c, charFrequency.getOrDefault(c, 0) + 1);
        }

        // 将字符频率映射到特征向量
        for (char c : charFrequency.keySet()) {
            int index = Math.abs(c) % VECTOR_DIMENSION;
            vector[index] += charFrequency.get(c);
        }

        // 归一化向量
        normalizeVector(vector);

        return vector;
    }

    /**
     * 归一化向量
     *
     * @param vector 输入向量
     */
    private void normalizeVector(double[] vector) {
        double magnitude = 0.0;

        // 计算向量模长
        for (double value : vector) {
            magnitude += value * value;
        }
        magnitude = Math.sqrt(magnitude);

        // 归一化向量
        if (magnitude > 0) {
            for (int i = 0; i < vector.length; i++) {
                vector[i] /= magnitude;
            }
        }
    }

3.2 表头内容相似度-余弦相似度

  • 我们将原始特征向量进行标准化(归一化)处理,使其转化为单位向量(模长为1),从而消除向量尺度差异对相似性度量的影响。(注:此步骤确保所有向量处于同一量纲空间,使得后续计算具有可比性)
  • 对于两个单位向量 u u u v v v,其点积在数值上等于它们的余弦相似度(即 c o s θ cosθ cosθ)。
    几何意义:余弦相似度反映向量方向的接近程度,与向量维度无关。
    数学表达
    c o s θ = u ⋅ v ∣ u ∣ ⋅ ∣ v ∣ cosθ=\frac{u·v}{|u|·|v|} cosθ=uvuv
    结果解释
    cos ⁡ θ ≈ 1 c o s θ ≈ 1 \cos\theta \approx 1cosθ≈1 cosθ1cosθ1:向量方向高度一致,对应字符串内容几乎相同。
    cos ⁡ θ ≈ 0 c o s θ ≈ 0 \cos\theta \approx 0cosθ≈0 cosθ0cosθ0:向量正交,字符串内容无相关性。
    应用示例:在文本匹配任务中,可通过该值量化两段文本的语义相似性。

点积与哈达玛积的区别:
点积输出标量,用于衡量整体相似性;
哈达玛积为元素级乘法,输出同维向量,常用于局部特征交互。

    private double cosineSimilarity(double[] vector1, double[] vector2) {
        if (vector1.length != vector2.length) {
            throw new IllegalArgumentException("向量维度不匹配");
        }

        double dotProduct = 0.0;
        double magnitude1 = 0.0;
        double magnitude2 = 0.0;

        for (int i = 0; i < vector1.length; i++) {
            dotProduct += vector1[i] * vector2[i];
            magnitude1 += vector1[i] * vector1[i];
            magnitude2 += vector2[i] * vector2[i];
        }

        magnitude1 = Math.sqrt(magnitude1);
        magnitude2 = Math.sqrt(magnitude2);

        if (magnitude1 == 0.0 || magnitude2 == 0.0) {
            return 0.0;
        } else {
            return dotProduct / (magnitude1 * magnitude2);
        }
    }

3.3 定时缓存清理

由于我们为了保证跨页表格的关联关系。我们使用map集合保存上一页表格内容。

    /**
     * 构造函数
     */
    public TableBatchProcessor() {
        // 使用虚拟线程池处理批量映射任务
        this.executorService = Executors.newVirtualThreadPerTaskExecutor();
        // 初始化表格缓冲队列
        this.tableBufferQueue = new LinkedBlockingQueue<>(BUFFER_CAPACITY);
        // 初始化表格类型计数器
        this.tableTypeCounter = new ConcurrentHashMap<>();
        // 初始化跨页表格缓存
        this.crossPageTableCache = new ConcurrentHashMap<>();
        // 初始化缓存清理调度器
        this.cacheCleanupScheduler = Executors.newScheduledThreadPool(1);
        // 启动定时清理任务
        this.cacheCleanupScheduler.scheduleAtFixedRate(this::cleanupCrossPageTableCache, 1, 1, TimeUnit.MINUTES);
    }

我设计了最早时间淘汰机制,同时为了进一步防止内存溢出。设计了map最大值。超出阈值清理所有。但显然这是有问题的,可能导致跨表关联关系断开。因此先以抛出异常解决

    /**
     * 清理跨页表格缓存(增强版)
     */
    private void cleanupCrossPageTableCache() {
        long currentTime = System.currentTimeMillis();
        List<String> expiredKeys = new ArrayList<>();

        for (Map.Entry<String, CacheEntry> entry : crossPageTableCache.entrySet()) {
            if (currentTime - entry.getValue().lastAccessTime > CACHE_ENTRY_TTL) {
                expiredKeys.add(entry.getKey());
            }
        }

        // 限制缓存条目数量
        if (crossPageTableCache.size() > MAX_CACHE_ENTRIES) {
            crossPageTableCache.clear();
            throw new IllegalStateException("缓存条目数量超过限制");
        }

        for (String key : expiredKeys) {
            crossPageTableCache.remove(key);
            log.info("清理过期缓存条目: {}", key);
        }
    }