Files
localRAG/docling_loader.py
T
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

320 lines
16 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Lädt mit prepare_corpus.py erzeugte Docling-Dokumente als LangChain-Dokumente.
Bereinigung (clean_blocks, reines Python, auch von grobid_qa.py benutzt):
- Seitenränder (Kolumnentitel, Seitenzahlen) fallen weg
- OCR-Zeichensalat vom Buchrand ("£", "X N £", "CE EEE VKU") wird verworfen,
Salat vor einem echten Absatz abgeschnitten
- an Spalten- oder Seitenwechseln getrennte Absätze werden wieder zusammengeführt,
Trennstriche aus Zeilenumbrüchen entfernt ("Veröf- fentlichung" -> "Veröffentlichung")
- Fußnotenzeichen im Fließtext werden entfernt ("Kafka.1", "Felice 6 , die")
- als Punkt gelesene Kommas werden repariert ("Einzelheiten. die" -> "Einzelheiten, die"),
als "©" gelesene Trennstriche am Zeilenrand entfernt ("Atmo- © sphäre")
- bekannte Kodierungsfehler einiger Verlags-PDFs werden ersetzt (" ¼ " -> " = ")
Jeder Chunk beginnt mit einer Kopfzeile aus Autor, Titel (aus dem Dateinamen) und
Abschnittspfad, z. B. "Lehmann, Hans-Thies: Der Buchstaebliche Koerper — 3. Schrift".
PyPDFLoader-Chunks tragen den Autor oft zufällig über den Kolumnentitel mit sich;
Docling sortiert Kolumnentitel aus und legt Überschriften in die Metadaten. Ohne
Kopfzeile fanden Fragen wie "nach Lehmann" die richtige Stelle deshalb schlechter.
Chunks (load): Fließtext wird pro Abschnitt zusammengefügt und mit demselben
Splitter wie bisher zerlegt, damit Vergleiche mit PyPDFLoader fair bleiben.
Fußnoten, Bildunterschriften und Tabellen werden als eigene Chunks indexiert,
Literaturverzeichnisse standardmäßig nicht. Metadaten je Chunk:
source Pfad des Original-PDFs (nicht der neu erkannten Kopie)
page Seite ab 0 im Original-PDF, wie bei PyPDFLoader (bei getrennten
Doppelseiten über <json>.pages.json zurückgerechnet)
half "L"/"R" bei getrennten Doppelseiten, sonst ""
element_type body | footnote | caption | table | reference
section Abschnittspfad aus der Überschriften-Hierarchie ("1. Nachlass › a) Situation 1924")
extractor "docling"
"""
import json
import re
import unicodedata
from dataclasses import dataclass, replace
from pathlib import Path
INDEX_REFERENCES = False # Literaturverzeichnis-Einträge mitindexieren?
LABEL_KIND = {
"title": "head", "section_header": "head", "text": "body", "paragraph": "body",
"list_item": "body", "footnote": "footnote", "caption": "caption", "formula": "body",
"code": "body", "reference": "reference", "document_index": "skip",
"page_header": "skip", "page_footer": "skip",
}
TERMINAL = tuple('.!?:…"\'“”‘’»«)]')
HYPHEN_KEEP = {"und", "oder", "bis", "sowie", "als", "wie", "noch", "bzw", "and", "or", "to"}
VOWEL = re.compile(r"[aeiouyäöüAEIOUYÄÖÜ]")
REAL_WORD = re.compile(r"[A-Za-zÄÖÜäöüß]{4,}")
NORMAL_TOKEN = re.compile(r"^[(„»\"']?[A-Za-zÄÖÜäöüß][a-zäöüß]{2,}[.,;:!?)»«\"“”']*$|^[(\[]?\d[\d:.,\-–]*[)\]]?[.,;:]?$")
# Kodierungsfehler aus Verlags-PDFs ohne Unicode-Zuordnung (siehe grobid_qa.py)
ENCODING_FIXES = [
(re.compile(r"(?<=\s)¼(?=\s)"), "="),
(re.compile(r"(?<=[\s(])À(?=\s?[\d.])"), "−"),
(re.compile(r"(?<=\w) Â (?=\w)"), " × "),
(re.compile(r"\bÓ (?=\d{4})"), "© "),
]
# Fußnotenzeichen am Wort ("Kafka1 ") oder nach Satzzeichen ("Kafka.1 ")
FNMARK_GLUED = re.compile(r"(?<=[a-zäöüß]{3})\d{1,3}(?=\s|$)")
FNMARK_AFTER_PUNCT = re.compile(r"(?<=[a-zäöüß][.,;:!?»“\"])\d{1,3}(?=\s|$)")
FNMARK_SPACED = re.compile(
r"(?<=[A-Za-zÄÖÜäöüß]{3}[.,;:!?»«\"”)]) (\d{1,3})(?: \d)? (?=[,;:]|[A-ZÄÖÜ„»])"
r"|(?<=[a-zäöüß]{3}) (\d{1,3})(?: \d)? (?=[,;:])")
# Abkürzungen, nach denen ein kleingeschriebenes Wort normal ist ("vgl. die", "Hrsg. von")
ABBREVIATIONS = {
"vgl", "bzw", "usw", "ebd", "sog", "ders", "dies", "hrsg", "hg", "anm", "aufl", "bd", "bde",
"nr", "verf", "übers", "eigtl", "wörtl", "bes", "ggf", "inkl", "evtl", "insb", "ca", "etc",
"kap", "abschn", "zit", "ff", "jh", "jhs", "dgl", "ugs", "lat", "griech", "frz", "engl",
"geb", "gest", "orig", "ausg", "hs", "hss", "fol", "masch", "diss", "phil",
}
PERIOD_BEFORE_LOWER = re.compile(r"\b([A-Za-zÄÖÜäöüß]*[a-zäöüß]{2})\. (?=[a-zäöüß]{2,}\b)")
def source_label(filename: str) -> str:
"""Autor und Titel aus Dateinamen wie 'Lehmann,_Hans-Thies_DerBuchstaeblicheKoerper_Zur….pdf'."""
stem = Path(filename).stem.strip(" ._")
tokens = [t for t in stem.split("_") if t]
authors, rest, i = [], [], 0
while i < len(tokens) and tokens[i].endswith(","): # "Nachname," "Vorname"
first = tokens[i + 1] if i + 1 < len(tokens) else ""
authors.append(f"{tokens[i].rstrip(',')}, {first}".strip(", "))
i += 2
rest = tokens[i:]
# Binnenmajuskeln trennen: "DerBuchstaeblicheKoerper" -> "Der Buchstaebliche Koerper"
title = " – ".join(re.sub(r"(?<=[a-zäöüß])(?=[A-ZÄÖÜ])", " ", t) for t in rest)
if not authors: # "Kafka_Die_Verwandlung_"
authors, title = [rest[0]] if rest else [], " ".join(rest[1:])
return "; ".join(authors) + (f": {title}" if title else "")
def chunk_header(source: str, section: str = "") -> str:
"""Kopfzeile, die jedem Chunk vorangestellt wird (Autor, Titel, Abschnitt)."""
return source_label(Path(source).name) + (f" — {section}" if section else "") + "\n\n"
def _repair_periods(text: str) -> str:
"""OCR liest Kommas oft als Punkte. Im Deutschen folgt auf einen Satzpunkt kein
kleingeschriebenes Wort, nach Abkürzungen schon ("Einzelheiten. die" -> "Einzelheiten, die")."""
return PERIOD_BEFORE_LOWER.sub(
lambda m: m.group(0) if m.group(1).lower() in ABBREVIATIONS else m.group(1) + ", ", text)
def period_anomalies_per_10k(text: str) -> float:
"""Wie oft ein Punkt vor einem kleingeschriebenen Wort steht (Abkürzungen ausgenommen)."""
hits = sum(m.group(1).lower() not in ABBREVIATIONS for m in PERIOD_BEFORE_LOWER.finditer(text))
return round(10000 * hits / max(1, len(text.split())), 1)
@dataclass
class Block:
kind: str # head, body, footnote, caption, table, reference
text: str
page: int | None # ab 1, wie Docling
section: str = ""
# Seitenwechsel innerhalb eines zusammengeführten Absatzes: (Zeichenposition, Seite)
page_breaks: tuple = ()
level: int = 1 # Ebene einer Überschrift (Docling, HeadingHierarchyOptions)
def _normalize(text: str) -> str:
text = unicodedata.normalize("NFC", text).replace("­", "")
text = " ".join(text.split())
# "©" ohne Jahreszahl ist ein falsch gelesener Trennstrich oder Randfleck ("Atmo- © sphäre")
text = re.sub(r"\s[©®](?!\s?\d{4})(?=\s)", "", text)
# Trennstrich aus Zeilenumbruch innerhalb eines Blocks
text = re.sub(r"(?<=[a-zäöüß])- (?=([a-zäöüß]+))",
lambda m: "- " if m.group(1) in HYPHEN_KEEP else "", text)
for pattern, repl in ENCODING_FIXES:
text = pattern.sub(repl, text)
return _repair_periods(text)
def is_garbage(text: str, kind: str = "body") -> bool:
"""OCR-Rauschen vom Buchrand oder aus dem Falz: kaum Buchstaben, keine echten Wörter.
Fußnoten sind oft nur Belege ("11 T S. 297.", "Vgl. Anm. 13.") und werden milder geprüft."""
nonspace = [c for c in text if not c.isspace()]
if not nonspace:
return True
letters = sum(c.isalpha() for c in nonspace)
if kind == "footnote":
return letters < 2 or letters / len(nonspace) < 0.25
if letters / len(nonspace) < 0.5:
return True
real = [w for w in REAL_WORD.findall(text) if VOWEL.search(w)]
if len(text) < 40 and not real:
return True
words = text.split()
if len(text) < 80 and sum(len(w) for w in words) / len(words) < 2.5:
return True
# lange Blöcke aus OCR-Salat (z. B. erkannte Handschrift-Faksimiles): kaum echte Wörter
return len(words) >= 8 and real_word_share(words) < 0.3
def real_word_share(words: list[str]) -> float:
"""Anteil 'echter' Wörter: mind. 4 Buchstaben, mit Vokal, überwiegend klein geschrieben.
Deutsche Prosa liegt bei etwa 0,5–0,6, OCR-Salat wie 'Bohran pn SE Hin A At' weit darunter."""
real = sum(bool(re.fullmatch(r"[„»(\"']?[A-ZÄÖÜa-zäöüß]?[a-zäöüß]{3,}[.,;:!?)«“\"']*", w))
and bool(VOWEL.search(w)) for w in words)
return real / max(1, len(words))
def strip_garbage_prefix(text: str) -> str:
"""Schneidet Zeichensalat vor einem echten Absatz ab: 'ET EST EEE "24 MEF = Re Doppler (1973:85) …'"""
tokens = text.split(" ")
i = 0
while i < len(tokens) and not NORMAL_TOKEN.match(tokens[i]):
i += 1
prefix = " ".join(tokens[:i])
if i >= 4 and re.search(r"[\"'=£®&>|~<{}]", prefix):
return " ".join(tokens[i:]).lstrip()
return text
def _strip_footnote_marks(text: str) -> str:
text = FNMARK_AFTER_PUNCT.sub("", FNMARK_GLUED.sub("", text))
return FNMARK_SPACED.sub(" ", text)
def _join(a: str, b: str) -> str:
"""Zwei Teile eines getrennten Absatzes verbinden, Trennstrich am Ende auflösen."""
if a.endswith("-") and b[:1].islower():
first = b.split(" ", 1)[0].strip(".,;:")
if first in HYPHEN_KEEP: # "Primär- und Sekundärtexte" bleibt
return a + " " + b
return a[:-1] + b # "Veröf-" + "fentlichung"
return a + " " + b
def read_blocks(path: Path) -> list[Block]:
"""Docling-JSON in Lesereihenfolge, ohne Seitenränder, Text normalisiert."""
doc = json.loads(Path(path).read_text(encoding="utf-8"))
blocks: list[Block] = []
def resolve(ref):
_, coll, idx = ref.split("/")
return coll, doc[coll][int(idx)]
def walk(node):
for child in node.get("children", []):
coll, item = resolve(child["$ref"])
prov = item.get("prov") or []
page = prov[0].get("page_no") if prov else None
if coll == "texts" and item.get("content_layer", "body") == "body":
kind = LABEL_KIND.get(item.get("label"), "body")
if kind != "skip":
raw = item.get("text", "")
text = _normalize(raw)
# Docling führt Absätze teils über Seiten hinweg; die Seitenwechsel
# stehen in prov (charspan bezogen auf den Rohtext, hier umgerechnet)
scale = len(text) / max(1, len(raw))
breaks = tuple((round(p["charspan"][0] * scale), p.get("page_no"))
for p in prov[1:] if p.get("charspan") and p.get("page_no") != page)
blocks.append(Block(kind, text, page, page_breaks=breaks, level=item.get("level") or 1))
elif coll == "tables" and item.get("content_layer", "body") == "body":
cells = (item.get("data") or {}).get("table_cells", [])
blocks.append(Block("table", _normalize(" ".join(c.get("text", "") for c in cells)), page))
walk(item)
walk(doc.get("body", {}))
return [b for b in blocks if b.text]
def clean_blocks(path: Path) -> list[Block]:
"""Bereinigte Blöcke mit Abschnittszuordnung, siehe Moduldoku."""
out: list[Block] = []
section, stack = "", [] # stack: (Ebene, Überschrift) der offenen Abschnitte
last_body = None # Index des letzten Fließtextblocks in out
for b in read_blocks(path):
if b.kind != "table":
b = replace(b, text=strip_garbage_prefix(b.text))
if is_garbage(b.text, b.kind):
continue
if b.kind == "head":
stack = [(lvl, txt) for lvl, txt in stack if lvl < b.level] + [(b.level, b.text)]
section = " › ".join(txt for _, txt in stack)
last_body = None # über Überschriften hinweg nie zusammenführen
out.append(replace(b, section=section))
continue
if b.kind == "body":
text = _strip_footnote_marks(b.text)
prev = out[last_body] if last_body is not None else None
if prev is not None and not prev.text.endswith(TERMINAL):
merged = _join(prev.text, text)
start = len(merged) - len(text)
breaks = prev.page_breaks + ((start, b.page),) + tuple((start + o, p) for o, p in b.page_breaks)
out[last_body] = replace(prev, text=merged, page_breaks=breaks)
continue
out.append(replace(b, text=text, section=section))
last_body = len(out) - 1
continue
out.append(replace(b, section=section))
return out
def load(json_path: Path, source: str, splitter, header: bool = True) -> list:
"""LangChain-Dokumente, gechunkt mit `splitter` (RecursiveCharacterTextSplitter).
header=True stellt jedem Chunk Autor, Titel und Abschnitt voran (chunk_header)."""
from langchain_core.documents import Document
blocks = clean_blocks(json_path)
# Getrennte Doppelseiten: neue Seite -> Seite im Original-PDF (split_spreads.py)
pages_file = Path(json_path).with_name(Path(json_path).stem + ".pages.json")
page_map = json.loads(pages_file.read_text(encoding="utf-8")) if pages_file.exists() else None
kinds = ["body", "footnote", "caption", "table"] + (["reference"] if INDEX_REFERENCES else [])
docs = []
for kind in kinds:
# Blöcke einer Art pro Abschnitt zusammenfassen, Seitenanfänge merken
groups: list[list[Block]] = []
for b in blocks:
if b.kind != kind:
continue
if groups and groups[-1][-1].section == b.section:
groups[-1].append(b)
else:
groups.append([b])
for group in groups:
text, starts = "", []
for b in group:
if text:
text += "\n\n"
starts.append((len(text), b.page))
starts += [(len(text) + offset, page) for offset, page in b.page_breaks]
text += b.text
def original_page(page):
"""Docling-Seite (ab 1) -> (Seite im Original-PDF ab 0, Hälfte)."""
if page_map and page and page <= len(page_map):
return page_map[page - 1]["original_page"], page_map[page - 1]["half"]
return ((page - 1) if page else None), ""
# Abschnitte des Texts mit ihrer Seite: [(Anfang, Ende, Seite), ...]
spans = [(s, e, p) for (s, p), (e, _) in zip(starts, starts[1:] + [(len(text), None)])]
cursor = 0
for chunk in splitter.split_text(text):
pos = text.find(chunk[:200], cursor)
pos = pos if pos >= 0 else max(text.find(chunk[:200]), 0)
cursor = pos + 1
# Seite, auf der der größte Teil des Chunks steht (nicht nur sein Anfang)
share = {}
for s, e, p in spans:
overlap = min(e, pos + len(chunk)) - max(s, pos)
if overlap > 0:
key = original_page(p)
share[key] = share.get(key, 0) + overlap
original, half = max(share, key=share.get) if share else original_page(group[0].page)
content = (chunk_header(source, group[0].section) if header else "") + chunk
docs.append(Document(page_content=content, metadata={
"source": source,
"page": original,
"half": half,
"element_type": kind,
"section": group[0].section,
"extractor": "docling",
}))
return docs