You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在RAG Pipeline中获取文本块的边界框(BBox)以高亮PDF文档中的Top5检索结果

如何在RAG Pipeline中获取文本块的边界框(BBox)以高亮PDF文档中的Top5检索结果

看起来你已经搭好了Haystack的RAG Pipeline,但卡在了分块与PDF高亮关联的关键步骤上——我之前也遇到过类似的问题,刚好可以给你一些实用的解决方案:

一、核心问题:在Haystack中保留PDF文本的边界框信息

你当前用的PyPDFToDocument转换器只提取了纯文本,没有保留文本块在PDF中的位置数据。要关联分块和BBox,首先得在PDF加载阶段就把每个文本块的位置抓下来,再跟着分块流程传递下去。

我推荐用**PyMuPDF(fitz)**实现自定义PDF加载器,它能精准提取每个文本块的页面号和边界框。下面是具体实现:

1. 自定义PDF加载器,提取带BBox的文本块

替换原有的PyPDFToDocument,用这个组件来抓取位置信息:

import fitz
from haystack import Document
from haystack.components.converters.base import BaseConverter

class PyMuPDFToDocumentWithBBox(BaseConverter):
    def convert(self, file_path: str, meta: dict = None) -> list[Document]:
        docs = []
        doc = fitz.open(file_path)
        for page_num in range(len(doc)):
            page = doc[page_num]
            # 获取页面上所有非空文本块,包含边界框和页面号
            text_blocks = page.get_text("blocks")
            page_blocks = []
            page_height = page.rect.height  # 记录页面高度,后续坐标转换用
            for block in text_blocks:
                x0, y0, x1, y1, text, _, _ = block
                if text.strip():
                    page_blocks.append({
                        "text": text,
                        "bbox": (x0, y0, x1, y1),
                        "page": page_num + 1,  # 页面号从1开始,和pdf.js保持一致
                        "page_height": page_height
                    })
            # 合并当前页面的文本,把块信息存入meta
            page_text = "\n".join([b["text"] for b in page_blocks])
            doc_meta = meta.copy() if meta else {}
            doc_meta["page_blocks"] = page_blocks
            docs.append(Document(content=page_text, meta=doc_meta))
        return docs

2. 拆分文本时关联BBox到每个Chunk

NLTKDocumentSplitter只处理纯文本,所以我们需要跟踪每个字符的来源块,把拆分后的Chunk和原始BBox绑定:

from haystack.components.preprocessors import NLTKDocumentSplitter

def split_with_bbox_tracking(doc: Document) -> list[Document]:
    splitter = NLTKDocumentSplitter(
        split_by="word",
        respect_sentence_boundary=False,
        split_length=200,
        split_overlap=40,
    )
    # 先拆分文本
    split_docs = splitter.run(documents=[doc])["documents"]
    
    # 构建文本位置到BBox的映射
    char_to_bbox = []
    for block in doc.meta["page_blocks"]:
        block_text = block["text"]
        # 给块内每个字符标记对应的BBox信息
        for _ in range(len(block_text)):
            char_to_bbox.append({
                "page": block["page"],
                "bbox": block["bbox"],
                "page_height": block["page_height"]
            })
        # 加上换行符的占位(因为我们用\n拼接了块文本)
        char_to_bbox.append(None)
    
    # 给每个Chunk匹配对应的BBox集合(去重避免重复高亮)
    final_docs = []
    for split_doc in split_docs:
        chunk_text = split_doc.content
        start_idx = doc.content.find(chunk_text)
        end_idx = start_idx + len(chunk_text)
        
        chunk_bboxes = []
        seen_bboxes = set()
        for idx in range(start_idx, end_idx):
            if idx >= len(char_to_bbox) or not char_to_bbox[idx]:
                continue
            bbox_info = char_to_bbox[idx]
            bbox_key = (bbox_info["page"], tuple(bbox_info["bbox"]))
            if bbox_key not in seen_bboxes:
                seen_bboxes.add(bbox_key)
                chunk_bboxes.append(bbox_info)
        
        # 更新Chunk的meta,存入BBox数据
        split_doc.meta["bboxes"] = chunk_bboxes
        final_docs.append(split_doc)
    
    return final_docs

3. 整合到你的现有Pipeline

把自定义组件和BBox跟踪逻辑加入索引流程:

# 初始化文档存储(保持你原来的配置)
document_store = QdrantDocumentStore(
    ":memory:",
    use_sparse_embeddings=True,
    recreate_index=True,
    embedding_dim=1024,
    return_embedding=False,
    wait_result_from_api=True,
)

# 加载PDF并处理BBox
converter = PyMuPDFToDocumentWithBBox()
pdf_docs = converter.convert("your_target_pdf.pdf")

# 对每个页面文档进行带BBox跟踪的拆分
split_docs_with_bbox = []
for doc in pdf_docs:
    split_docs_with_bbox.extend(split_with_bbox_tracking(doc))

# 构建嵌入和写入Pipeline
indexing_pipeline = Pipeline()
indexing_pipeline.add_component("sparse_doc_embedder", FastembedSparseDocumentEmbedder(model="some model"))
indexing_pipeline.add_component("dense_doc_embedder", SentenceTransformersDocumentEmbedder(model="some model"))
indexing_pipeline.add_component("writer", DocumentWriter(document_store=document_store, policy=DuplicatePolicy.OVERWRITE))

indexing_pipeline.connect("sparse_doc_embedder", "dense_doc_embedder")
indexing_pipeline.connect("dense_doc_embedder", "writer")

# 运行Pipeline
indexing_pipeline.run({"sparse_doc_embedder": {"documents": split_docs_with_bbox}})

二、处理跨页面的边界框重叠问题

当Chunk跨页时,它的meta["bboxes"]会包含多个页面的BBox数据,处理起来很简单:

  1. 坐标转换:PyMuPDF的Y坐标从页面底部开始计算,而pdf.js从顶部开始,需要转换:

    function convertBBox(originalBBox, pageHeight) {
        const [x0, y0, x1, y1] = originalBBox;
        return [
            x0,
            pageHeight - y1,
            x1,
            pageHeight - y0
        ];
    }
    
  2. 按页面分组高亮:把BBox按页面号分组,对每个页面单独处理高亮。pdf.js的高亮基于单页面,同一页面的多个BBox可以叠加显示,不会冲突:

    async function highlightRetrievedChunks(pdfDoc, retrievedChunks) {
        // 按页面号分组所有BBox
        const bboxesByPage = {};
        retrievedChunks.forEach(chunk => {
            chunk.meta.bboxes.forEach(bboxInfo => {
                const pageNum = bboxInfo.page;
                if (!bboxesByPage[pageNum]) bboxesByPage[pageNum] = [];
                bboxesByPage[pageNum].push(bboxInfo);
            });
        });
    
        // 遍历每个页面添加高亮
        for (const pageNumStr in bboxesByPage) {
            const pageNum = parseInt(pageNumStr);
            const page = await pdfDoc.getPage(pageNum);
            const viewport = page.getViewport({ scale: 1 });
            const canvas = document.getElementById(`pdf-page-${pageNum}`);
            const ctx = canvas.getContext('2d');
            canvas.height = viewport.height;
            canvas.width = viewport.width;
    
            // 先渲染页面内容
            await page.render({ canvasContext: ctx, viewport }).promise;
    
            // 添加高亮标记
            bboxesByPage[pageNum].forEach(bboxInfo => {
                const convertedBBox = convertBBox(bboxInfo.bbox, bboxInfo.page_height);
                const highlight = new pdfjsLib.AnnotationHighlight({
                    rect: convertedBBox,
                    contents: "相关检索内容"
                });
                page.addAnnotation(highlight);
                // 重新渲染显示高亮
                page.render({ canvasContext: ctx, viewport }).promise;
            });
        }
    }
    

三、额外小贴士

  • 去重优化:生成Chunk的BBox时记得去重,避免同一个文本块被多次高亮。
  • 内存优化:如果PDF很大,逐字符跟踪内存占用高,可以改用按块的起始/结束位置匹配,判断Chunk落在哪些块范围内。
  • 元数据复用:把页面高度提前存入Document的meta,前端直接拿取即可,不用重复计算。

备注:内容来源于stack exchange,提问作者Oleksandr Khivrych

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.14 14:54:38