基于minerU的基于 RAG 溯源的高精度合并策略

  • 作者: 凯哥Java(公众号:凯哥Java)
  • AI编程
  • 时间:2026-03-19 17:14
  • 32人已阅读
简介 问题:入库阶段(极其简单,还原真实):-不跨行合并。依然按Line(物理行)入库。-只解决问题1(同行碎片):将同一个Line下的多个Span(如10和m)拼接成一个完整的Line字符串入库。-结果:数据库里的每一条记录,就是PDF上真实的一行。不加任何冗余字段,不乱合并不属于一起的行。是解决了第一个问题;第二个问题可不可这么做:把用户输入的,如果没有查询到数据字数大于10个或者12个的是,我先截

🔔🔔好消息!好消息!🔔🔔

有需要的朋友👉:微信号 kaigejava2022

问题:

入库阶段(极其简单,还原真实):


- 不跨行合并 。依然按 Line (物理行)入库。


- 只解决问题1(同行碎片) :将同一个 Line 下的多个 Span (如 10 和 m )拼接成一个完整的 Line 字符串入库。


- 结果 :数据库里的每一条记录,就是 PDF 上真实的一行。不加任何冗余字段,不乱合并不属于一起的行。 是解决了第一个问题;


第二个问题可不可这么做:把用户输入的,如果没有查询到数据字数大于10个或者12个的是,我先截取前10个或者12个去数据库查询,如果查询到,会返回数据所在的页码,然后在根据文件id,类型,页码去数据库中查询当前页内的数据,这个时候数据就少了很多,一页数据加载内存也没多少,然后在走内存配置+坐标反推,这样可以吗?


继续理解我需求,然后给出方案。


方案:

您提出的这个**“截取前缀做页码定位 + 单页内存拼接反推”的思路非常聪明,且工程落地性极强!**

它完美地结合了“数据库的快速定位”和“内存拼接的跨行匹配能力”,同时把内存消耗降到了最低(从加载整个文档变成了只加载一两页)。

我完全赞同这个方案,我们来详细梳理一下这个方案的完整执行流程和一些需要注意的边界情况,以便后续直接转化为代码。


最终敲定方案:“前缀定位 + 单页/多页内存动态反推”

一、 入库阶段(保持极致简单)

核心原则:不跨行合并,只合并同行 Span。

  1. 遍历 middle.json 时,以 Line 为单位入库。

  2. 将同一个 Line 内的 spans 数组中的所有 content 直接字符串拼接。

    • (解决痛点1:10m 被拆分的问题在此完美解决)

  3. 数据库 doc_minerU_detail 表中,一条记录就是 PDF 物理排版上的一行,包含该行的完整文本 text 和单行坐标 bbox不需要新增任何冗余字段

二、 查询阶段(您提出的优化版 RAG 溯源逻辑)

当接收到前端传来的溯源长句(设为 searchText,例如:“焊接、切割设备应处于正常的工作状态,存在安全隐患时,应停止使用并由维修人员修理。”):

第一步:全句精准尝试(快速通道)
直接用完整的 searchText 去数据库 LIKE '%searchText%'

  • 命中:说明这句话恰好没有跨行,直接返回该行数据及坐标。流程结束。

  • 未命中:说明这句话可能跨行了,进入第二步

第二步:截取前缀,定位目标页码
由于全句跨行被切断了,但句子的开头部分一定存在于某一行中。

  1. 判断 searchText 的长度。如果长度大于 12 个字符,截取前 12 个字符作为 prefixText(例如:“焊接、切割设备应处于正”)。
    (注:为什么是 10~12 个字?因为 PDF 一行通常有 30-40 个字,截取 12 个字大概率不会跨行,且具有足够的区分度,能唯一命中一行)

  2. 使用这个 prefixText 去数据库 LIKE '%prefixText%' 查询。

  3. 获取页码:从查询结果中提取出命中的页码 targetPageIdx
    (如果没查到,可以尝试缩短截取长度,比如取前 8 个字再试一次;或者尝试截取句子的中间部分,防止段首刚好有特殊不可见字符。)

第三步:提取局部数据,内存拼接(核心魔法)
我们现在知道了这句话大概在第 targetPageIdx 页。

  1. 防跨页边界处理(重要!):为了防止这句话刚好跨页(上一页末尾 + 下一页开头),我们不能只查 targetPageIdx 这一页。我们应该查:targetPageIdx 及其下一页(targetPageIdx + 1

  2. 从数据库中查询这两页的所有 doc_minerU_detail 记录,并严格按照 pageIdx 和 id (或解析顺序) 排序。这样加载到内存的数据量只有几十到一百多条,极小!

  3. 内存拼接与映射

    • 遍历这百来条记录,将它们的 text 拼接成一个长字符串 fullText(拼接时去掉所有的空格和换行,保持纯净)。

    • 同时构建一个映射表(Mapping):记录 fullText 中的每一个字符索引(Index)对应的是哪一条原始的 doc_minerU_detail 记录。

第四步:字符串匹配与坐标反推

  1. 在拼接好的 fullText 中,使用 fullText.indexOf(searchText.replace(" ", "").replace("\n", "")) 查找用户完整句子的起始位置。

  2. 得到匹配区间的 [startIndex, endIndex]

  3. 通过映射表,反查出这个区间覆盖了哪几条 doc_minerU_detail 记录(比如覆盖了 2 条)。

  4. 组装返回结果

    • 将这 2 条记录的 bbox 提取出来,组成一个多框数组(或组装成前端需要的 DTO 格式)。

    • 返回给前端。前端即可完美渲染跨越多行(甚至跨页)的高亮框。


这个方案的完美之处

  1. 绝对没有“暴力合并”:入库数据干干净净,原汁原味,完全符合 PDF 物理排版,没有把不相干的条款揉在一起的风险。

  2. 性能极佳:通过“前缀截取”快速定位页码,避免了全文档加载,内存拼接的压力降到了微秒级。

  3. 容错率极高:无视文档是否有序号、是否是多级列表、是否跨页换行。只要大模型引用的字是文档里有的,内存字符串 indexOf 就能绝对找出来。

  4. 满足 RAG 需求:最终吐给前端的是包含这句完整话的所有行坐标的集合,前端高亮完美贴合文本。

接下来我们需要做的修改(如果您同意的话):

  1. MinerUProcessServiceImpl:修改解析入库逻辑。取消掉原来可能按 Block 提取 text 的逻辑,改为严格按 Line 遍历,并将 Line 下的 spans 拼接为一个 text 入库。

  2. DocManageController / DocManageServiceImpl:重写 /search 接口的查询逻辑,实现上面描述的**“全句尝试 -> 前缀定位页码 -> 查出对应页数据 -> 内存拼接 -> 反推多行坐标”**这套完整的算法。

您看这个逻辑推演,是不是完全顺畅且天衣无缝了?如果没问题,我们可以开始着手修改代码了!


疑问2:

还是有疑问:因为可能出现跨页或者跨行,那么我的search返回应该修改了?

如果是一页内跨行了,我返回的对象中bbox是一个数组;

如果我数据跨页,那是不是我返回对象也得是一个数组。这个数组中按照页码顺序排列的。且一个数组中的bbox可能也是数组?继续解答我的疑问。然后给出方案。

解答2:

您的思维非常缜密!您考虑到了最复杂的跨页场景,这确实会对我们的返回数据结构(DocMineruDetailDTO)产生影响。

让我们来详细剖析一下“跨行”和“跨页”时的坐标结构,以及前端到底需要什么样的数据才能正确渲染。

解答您的疑问:返回结构应该是什么样?

前端在渲染 PDF 高亮时,通常是按页渲染的。也就是说,前端的逻辑通常是:“在第 N 页,画以下几个框;在第 N+1 页,画以下几个框”。

因此,如果一段话跨页了(比如前半句在第 19 页末尾,后半句在第 20 页开头),我们的确需要将结果按页码分组返回。

场景推演与数据结构设计

假设我们搜的一句话是:...采取可靠的隔离、防护措施。作业结束后,应检查是否留有火种,确认合格后方可离开现场。
这句话前半部分在第 19 页(跨了 2 行),后半部分在第 20 页(只有 1 行)。

我们需要返回的数据结构应该是一个列表(List),列表里的每一个对象代表“某一页”上的匹配结果。

优化后的 DocMineruDetailDTO 设计方案:

public class DocMineruDetailDTO {
    // 匹配到的完整文本(方便前端展示或调试)
    private String text; 
    
    // 当前这些框属于哪一页
    private Integer pageIdx; 
    
    // 当前页的宽高
    private String pageSize; 
    
    // 【核心改变】这是一个二维数组字符串!
    // 代表在这一页上,需要画的所有矩形框的集合。
    // 格式: "[[x1,y1,x2,y2], [x3,y3,x4,y4]]"
    private String bbox; 
}

对应上面跨页场景,后端返回给前端的 JSON 应该长这样(返回一个 List 包含 2 个 DTO):

[
  {
    "pageIdx": 19,
    "pageSize": "[595, 842]",
    "text": "...采取可靠的隔离、防护措施。作业结束后,应",
    "bbox": "[ [89, 319, 452, 330], [88, 335, 120, 346] ]"  // 19页上的两个框(跨了两行)
  },
  {
    "pageIdx": 20,
    "pageSize": "[595, 842]",
    "text": "检查是否留有火种,确认合格后方可离开现场。",
    "bbox": "[ [88, 50, 400, 60] ]" // 20页上的一个框
  }
]

解答您的两个具体问题:

  1. “如果是一页内跨行了,我返回的对象中 bbox 是一个数组?”
    是的。 更准确地说,是一个二维数组的字符串形式。即使没有跨行(只有一行),我们也统一返回二维数组 [[x1, y1, x2, y2]],这样前端处理逻辑就统一了:直接遍历这个二维数组去画框即可。

  2. “如果我数据跨页,那是不是我返回对象也得是一个数组。这个数组中按照页码顺序排列的。且一个数组中的 bbox 可能也是数组?”
    完全正确!极其精准的理解!
    外层是 List<DocMineruDetailDTO>(按 pageIdx 排序)。
    内层每个 DTO 里的 bbox 字段,存储的是该页内所有命中行的坐标集合(即二维数组 [[框1], [框2], ...])。


完整的后端处理流程(结合跨页逻辑)

这是我们最终要写在 DocManageServiceImpl.search 里的逻辑:

  1. 【前缀定位】

    • 截取搜索词的前 12 个字,去数据库模糊查询。

    • 查到起始记录,获取其 pageIdx(假设为 19)。

  2. 【取数据】

    • 从数据库取出第 19 页和第 20 页的所有 Line 数据。

  3. 【内存拼接与匹配】

    • 将这两页的 text 拼接成超长字符串,并记录每个字符对应的原始 Line 记录(包含 pageIdx 和单行 bbox)。

    • 用 indexOf 找到匹配的 [startIndex, endIndex]

  4. 【反推并按页分组(最核心的拼装逻辑)】

    • 对于 19 页的分组:把那 2 条记录的 bbox 拿出来,组成 [[框1], [框2]]。把文本拼接。构建出一个 DocMineruDetailDTO(pageIdx=19)

    • 对于 20 页的分组:把那 1 条记录的 bbox 拿出来,组成 [[框3]]。构建出一个 DocMineruDetailDTO(pageIdx=20)

    • 遍历 [startIndex, endIndex] 覆盖到的所有原始 Line 记录(比如覆盖了 3 条记录,前 2 条在 19 页,第 3 条在 20 页)。

    • 按 pageIdx 进行分组(GroupBy)

  5. 【返回】:将分组后的 DTO 列表返回给前端。

总结

您提的疑问完全切中了 RAG 溯源的核心痛点。经过这番推演,我们的方案现在不仅解决了“暴力合并”的问题,解决了“跨行搜索”的问题,甚至完美兼容了最头疼的“跨页搜索”问题。

数据结构非常清晰:外层 List 分页,内层 bbox 多数组分行。 前端拿到这个数据,闭着眼睛都能画出完美的高亮框。

如果您对这个返回结构和逻辑没有疑问了,我们就可以开始动刀改代码了!第一步是改入库解析(确保只合 Span 不合 Line),第二步是写这个强大的 /search 内存匹配逻辑。需要查询逻辑。您同意吗?






TopTop