Add Docling corpus pipeline, retrieval evaluation and hybrid search with reranking
- prepare_corpus.py: split double-page scans (split_spreads.py), re-OCR with OCRmyPDF/Tesseract only where the existing text layer is worse, Docling layout extraction with heading hierarchy, QA report per PDF - docling_loader.py: cleanup (margin noise, split paragraphs, footnote marks, OCR comma/period repair), page mapping back to the original PDF, chunk headers with author, title and section - local_rag.py: bge-m3 embeddings, configurable chunking and loader, index settings check (index_info.json), hybrid/union BM25 search with Reciprocal Rank Fusion and bge-reranker-v2-m3 cross-encoder; query instruction text removed after it lowered recall - eval_retrieval.py: CLI options, same retrieval path as the chat, tolerant file name matching; goldsets and evaluation reports for the Kafka corpus - grobid_test.py, grobid_qa.py, extract_docling.py, compare_extractors.py: extractor tests and quality checks - .gitignore: corpus PDFs, vector stores and extractor output stay local - server.py: uses ./vectorstore_docling_hdr Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Trennt Doppelseiten-Scans in Einzelseiten, bevor OCR und Docling sie lesen.
|
||||
|
||||
Bei den meisten Titeln im Kafka-Korpus liegen zwei Buchseiten auf einer PDF-Seite
|
||||
(Querformat). Tesseract liest dann stellenweise quer über den Falz, Doclings
|
||||
Layout-Modell trennt Absätze an der Seitenmitte. Hier wird jede Querformat-Seite
|
||||
in zwei Seiten zugeschnitten: Der Falz wird im Bild gesucht, als die senkrechte
|
||||
Spalte in der Seitenmitte mit den wenigsten Hell-Dunkel-Wechseln (Text erzeugt
|
||||
viele, Innenrand und Falzschatten kaum welche). Bildinhalt und Textschicht bleiben
|
||||
unverändert, nur der sichtbare Seitenbereich (MediaBox/CropBox) wird gesetzt —
|
||||
Docling und OCRmyPDF halten sich daran.
|
||||
|
||||
Neben jedes getrennte PDF wird <pdf>.pages.json geschrieben: zu jeder neuen Seite
|
||||
die Seite im Original-PDF (ab 0) und die Hälfte ("L", "R" oder "" für ungeteilt).
|
||||
docling_loader.py rechnet Seitenangaben damit auf das Original zurück.
|
||||
|
||||
Benutzt von prepare_corpus.py; läuft in der Docling-Umgebung (pypdfium2, numpy).
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pypdfium2 as pdfium
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
from pypdf.generic import RectangleObject
|
||||
|
||||
LANDSCAPE_RATIO = 1.15 # Breite/Höhe, ab der eine Seite als Doppelseite gilt
|
||||
SEARCH_BAND = (0.35, 0.65) # Falz wird in diesem Bereich der Seitenbreite gesucht
|
||||
RENDER_SCALE = 0.5 # grobes Vorschaubild genügt für die Falzsuche
|
||||
|
||||
|
||||
def find_gutter(page: pdfium.PdfPage) -> float:
|
||||
"""Lage des Falzes als Anteil der Seitenbreite (0..1)."""
|
||||
gray = np.asarray(page.render(scale=RENDER_SCALE).to_pil().convert("L"), dtype=np.int16)
|
||||
h, w = gray.shape
|
||||
band = gray[int(h * 0.08):int(h * 0.92)] # Ober- und Unterkante meiden
|
||||
transitions = (np.abs(np.diff(band, axis=0)) > 40).sum(axis=0).astype(float)
|
||||
k = max(3, w // 100)
|
||||
smooth = np.convolve(transitions, np.ones(k) / k, mode="same")
|
||||
lo, hi = int(w * SEARCH_BAND[0]), int(w * SEARCH_BAND[1])
|
||||
window = smooth[lo:hi]
|
||||
# Mitte des längsten Abschnitts nahe dem Minimum: der leere Streifen zwischen den Seiten
|
||||
quiet = window <= window.min() + 0.15 * (np.median(window) - window.min())
|
||||
best, start, run = (0, 0), None, []
|
||||
for i, q in enumerate(list(quiet) + [False]):
|
||||
if q and start is None:
|
||||
start = i
|
||||
elif not q and start is not None:
|
||||
best = max(best, (i - start, start))
|
||||
start = None
|
||||
length, first = best
|
||||
return (lo + first + length / 2) / w
|
||||
|
||||
|
||||
def split_pdf(src: Path, dst: Path) -> list[dict]:
|
||||
"""Schreibt das getrennte PDF nach dst und liefert die Seitenzuordnung."""
|
||||
reader = PdfReader(src)
|
||||
preview = pdfium.PdfDocument(str(src))
|
||||
writer = PdfWriter()
|
||||
mapping = []
|
||||
for i, page in enumerate(reader.pages):
|
||||
box = page.mediabox
|
||||
left, bottom, right, top = (float(v) for v in (box.left, box.bottom, box.right, box.top))
|
||||
rotated = int(page.get("/Rotate", 0) or 0) % 180 != 0
|
||||
if rotated or (right - left) <= LANDSCAPE_RATIO * (top - bottom):
|
||||
writer.add_page(page)
|
||||
mapping.append({"original_page": i, "half": ""})
|
||||
continue
|
||||
x = left + find_gutter(preview[i]) * (right - left)
|
||||
for half, (x0, x1) in (("L", (left, x)), ("R", (x, right))):
|
||||
new = writer.add_page(page)
|
||||
rect = RectangleObject([x0, bottom, x1, top])
|
||||
new.mediabox = rect
|
||||
new.cropbox = rect
|
||||
mapping.append({"original_page": i, "half": half, "gutter_x": round(x, 1)})
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(dst, "wb") as f:
|
||||
writer.write(f)
|
||||
pages_json(dst).write_text(json.dumps(mapping, indent=1), encoding="utf-8")
|
||||
return mapping
|
||||
|
||||
|
||||
def pages_json(pdf: Path) -> Path:
|
||||
return pdf.with_name(pdf.stem + ".pages.json")
|
||||
|
||||
|
||||
def is_spread_pdf(pdf: Path, min_share: float = 0.5) -> bool:
|
||||
"""Liegt bei mindestens min_share der Seiten Querformat vor?"""
|
||||
pages = PdfReader(pdf).pages
|
||||
land = sum(float(p.mediabox.width) > LANDSCAPE_RATIO * float(p.mediabox.height) for p in pages)
|
||||
return land >= min_share * len(pages)
|
||||
|
||||
|
||||
def bounded_page_texts(pdf: Path) -> list[str]:
|
||||
"""Text jeder Seite, auf den sichtbaren Bereich begrenzt (pypdf ignoriert den Zuschnitt)."""
|
||||
doc = pdfium.PdfDocument(str(pdf))
|
||||
texts = []
|
||||
for page in doc:
|
||||
left, bottom, right, top = page.get_cropbox()
|
||||
texts.append(page.get_textpage().get_text_bounded(left, bottom, right, top))
|
||||
return texts
|
||||
Reference in New Issue
Block a user