如何在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数据,处理起来很简单:
坐标转换:PyMuPDF的Y坐标从页面底部开始计算,而pdf.js从顶部开始,需要转换:
function convertBBox(originalBBox, pageHeight) { const [x0, y0, x1, y1] = originalBBox; return [ x0, pageHeight - y1, x1, pageHeight - y0 ]; }按页面分组高亮:把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
相关产品推荐
相关产品推荐

