Files
localRAG/extract_docling.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

110 lines
4.9 KiB
Python

"""
PDFs mit Docling extrahieren, zum Vergleich mit GROBID und PyPDFLoader.
Vier Varianten:
docling nutzt die vorhandene Textschicht des PDFs (bei Scans: deren OCR)
docling_ocr erkennt jede Seite neu per OCR (Apple Vision über ocrmac, Deutsch),
ignoriert also die mitgelieferte, oft schlechte OCR-Schicht.
Achtung: lässt bei diesen Scans ganze Absätze aus.
docling_tess wie docling_ocr, aber mit Tesseract und dem deutschen Modell
aus tessdata/deu.traineddata (tessdata_best). Verliert ebenfalls Text:
Doclings eigener OCR-Ablauf ist bei diesen Doppelseiten-Scans unzuverlässig.
docling_ocrmypdf
Arbeitsteilung: OCRmyPDF (Tesseract, deutsch, mit Geraderichten) legt
eine neue Textschicht ins PDF (extract_out/ocrmypdf/), Docling liest
diese dann ohne eigene OCR und erkennt die Struktur.
Läuft in einer eigenen Umgebung, weil Docling PyTorch und Layout-Modelle mitbringt:
conda activate docling
python extract_docling.py # Variante docling_tess, Titel aus TITLES
python extract_docling.py docling # andere Variante
Ausgabe: extract_out/<variante>/<pdf>.json (verlustfrei, für grobid_qa.py)
und .md (zum Lesen). Vorhandene Ergebnisse werden übersprungen.
"""
import os
import subprocess
import sys
import time
from pathlib import Path
from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import (
OcrMacOptions, OcrMode, PdfPipelineOptions, TesseractCliOcrOptions)
from docling.document_converter import DocumentConverter, PdfFormatOption
PDF_DIR = Path("data")
OUT_ROOT = Path("extract_out")
TESSDATA = Path("tessdata").resolve() # enthält deu.traineddata
# Docling prüft die verfügbaren Tesseract-Sprachen ohne Rücksicht auf `path`,
# deshalb das Modellverzeichnis auch über die Umgebungsvariable bekanntmachen.
os.environ["TESSDATA_PREFIX"] = str(TESSDATA)
VARIANTS = ("docling", "docling_ocr", "docling_tess", "docling_ocrmypdf")
OCRMYPDF_DIR = OUT_ROOT / "ocrmypdf" # neu erkannte PDFs für docling_ocrmypdf
# Auswahl für den Vergleich: Dateinamen beginnen mit diesen Präfixen
TITLES = [
"Beissner,", # Aufsatz, viele Fußnoten
"Fiechter,", # GROBID: alles ein Absatz
"Trost,", # sehr schlechte OCR-Schicht
"Harzer,", # GROBID: Fließtext als Abbildung
"Binder,_Hartmut_KafkaHandbuch_Band2_AText", # großes Buch
]
def converter(variant: str) -> DocumentConverter:
opts = PdfPipelineOptions()
opts.do_table_structure = True
if variant == "docling_ocr":
opts.do_ocr = True
opts.ocr_options = OcrMacOptions(mode=OcrMode.FULL_PAGE, lang=["de-DE"])
elif variant == "docling_tess":
opts.do_ocr = True
opts.ocr_options = TesseractCliOcrOptions(mode=OcrMode.FULL_PAGE, lang=["deu"], path=str(TESSDATA))
else: # docling, docling_ocrmypdf: Textschicht lesen
opts.do_ocr = False
return DocumentConverter(format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=opts)})
def ocrmypdf(pdf: Path) -> Path:
"""Legt mit OCRmyPDF eine neue deutsche Textschicht an (überspringt Vorhandenes)."""
OCRMYPDF_DIR.mkdir(parents=True, exist_ok=True)
target = OCRMYPDF_DIR / pdf.name
if not target.exists():
subprocess.run(
[str(Path(sys.executable).parent / "ocrmypdf"), "--force-ocr", "-l", "deu", "--deskew",
"--output-type", "pdf", "--jobs", "4", "--quiet", str(pdf), str(target)],
check=True)
return target
def main():
variant = sys.argv[1] if len(sys.argv) > 1 else "docling_tess"
if variant not in VARIANTS:
sys.exit(f"Variante muss eine von {', '.join(VARIANTS)} sein")
out_dir = OUT_ROOT / variant
out_dir.mkdir(parents=True, exist_ok=True)
pdfs = [p for p in sorted(PDF_DIR.glob("*.pdf")) if any(p.name.startswith(t) for t in TITLES)]
conv = converter(variant)
for i, pdf in enumerate(pdfs, 1):
target = out_dir / f"{pdf.stem}.json"
if target.exists():
print(f"[{i}/{len(pdfs)}] {pdf.name}: vorhanden, übersprungen", flush=True)
continue
print(f"[{i}/{len(pdfs)}] {pdf.name} ...", end=" ", flush=True)
t0 = time.time()
try:
source = ocrmypdf(pdf) if variant == "docling_ocrmypdf" else pdf
doc = conv.convert(source).document
except Exception as e: # ein kaputtes PDF soll den Lauf nicht abbrechen
print(f"FEHLER: {e}", flush=True)
continue
doc.save_as_json(target)
doc.save_as_markdown(out_dir / f"{pdf.stem}.md")
print(f"fertig ({time.time() - t0:.0f} s)", flush=True)
if __name__ == "__main__":
main()