Files
localRAG/split_spreads.py
Philipp MockandClaude Opus 5.5 d54b3e5b8b 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>
2026-10-07 16:48:12 +02:00

103 lines
4.4 KiB
Python

"""
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