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:
2026-10-07 16:48:12 +02:00
co-authored by Claude Opus 5.5
parent 7d70169ee3
commit d54b3e5b8b
49 changed files with 5007 additions and 75766 deletions
+121
View File
@@ -0,0 +1,121 @@
"""
Testlauf: PDFs aus data_test/ durch GROBID schicken und das Ergebnis begutachten.
Voraussetzung: GROBID-Container läuft (z. B. auf http://localhost:8070).
Schreibt pro PDF eine TEI-XML-Datei und eine lesbare Textfassung nach grobid_out/
und gibt eine kurze Übersicht aus, was GROBID als Fließtext, Fußnote und
Literaturangabe erkannt hat.
Nur Standardbibliothek, kein zusätzliches Paket nötig.
"""
import sys
import uuid
import urllib.request
import xml.etree.ElementTree as ET
from pathlib import Path
GROBID_URL = "http://localhost:8070"
INPUT_DIR = Path("data_test")
OUTPUT_DIR = Path("grobid_out") / INPUT_DIR.name
TEI = {"tei": "http://www.tei-c.org/ns/1.0"}
def process_pdf(pdf_path: Path) -> bytes:
"""Schickt ein PDF an processFulltextDocument und liefert TEI-XML zurück."""
boundary = uuid.uuid4().hex
fields = [
("consolidateHeader", "0"), # keine Online-Abfrage (CrossRef) für Metadaten
("consolidateCitations", "0"),
("segmentSentences", "0"),
("includeRawCitations", "1"), # Literaturangaben zusätzlich im Originalwortlaut
]
# Seitenkoordinaten an diese Elemente hängen (Format "seite,x,y,b,h;..."),
# damit sich jedem Absatz eine Seitenzahl zuordnen lässt.
fields += [("teiCoordinates", el) for el in ("head", "p", "note", "figure", "biblStruct")]
body = b""
for name, value in fields:
body += (f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n"
f"{value}\r\n").encode()
body += (f"--{boundary}\r\nContent-Disposition: form-data; name=\"input\"; "
f"filename=\"{pdf_path.name}\"\r\nContent-Type: application/pdf\r\n\r\n").encode()
body += pdf_path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
req = urllib.request.Request(
f"{GROBID_URL}/api/processFulltextDocument",
data=body,
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
# ganze Monografien brauchen deutlich länger als Zeitschriftenartikel
with urllib.request.urlopen(req, timeout=1800) as resp:
return resp.read()
def text_of(el) -> str:
return " ".join("".join(el.itertext()).split())
def summarize(tei_bytes: bytes) -> tuple[dict, str]:
"""Zählt die erkannten Elemente und baut eine lesbare Textfassung."""
root = ET.fromstring(tei_bytes)
title = root.find(".//tei:titleStmt/tei:title", TEI)
authors = list(dict.fromkeys( # GROBID listet Autoren gelegentlich doppelt
" ".join(s.strip() for s in p.itertext() if s.strip())
for p in root.findall(".//tei:sourceDesc//tei:author/tei:persName", TEI)))
abstract = root.find(".//tei:profileDesc/tei:abstract", TEI)
body = root.find(".//tei:text/tei:body", TEI)
divs = body.findall("tei:div", TEI) if body is not None else []
paras = body.findall(".//tei:p", TEI) if body is not None else []
notes = root.findall(".//tei:note[@place='foot']", TEI)
refs = root.findall(".//tei:back//tei:listBibl/tei:biblStruct", TEI)
stats = {
"titel": text_of(title) if title is not None else "",
"autoren": ", ".join(authors),
"abschnitte": len(divs),
"absaetze": len(paras),
"zeichen_fliesstext": sum(len(text_of(p)) for p in paras),
"fussnoten": len(notes),
"literaturangaben": len(refs),
}
out = [f"# {stats['titel']}", f"Autoren: {stats['autoren']}", ""]
if abstract is not None and text_of(abstract):
out += ["## Abstract", text_of(abstract), ""]
for div in divs:
head = div.find("tei:head", TEI)
if head is not None:
out += [f"## {text_of(head)}"]
for p in div.findall("tei:p", TEI):
out += [text_of(p), ""]
out += ["", "=" * 70, f"## Fußnoten ({len(notes)})"]
for n in notes:
out += [f"[{n.get('n', '?')}] {text_of(n)}"]
out += ["", "=" * 70, f"## Literaturverzeichnis ({len(refs)})"]
for r in refs:
out += [f"- {text_of(r)}"]
return stats, "\n".join(out)
def main():
try:
urllib.request.urlopen(f"{GROBID_URL}/api/isalive", timeout=5)
except Exception as e:
sys.exit(f"GROBID unter {GROBID_URL} nicht erreichbar: {e}")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
for pdf in sorted(INPUT_DIR.glob("*.pdf")):
print(f"\n{pdf.name} ...", flush=True)
tei = process_pdf(pdf)
(OUTPUT_DIR / f"{pdf.stem}.tei.xml").write_bytes(tei)
stats, text = summarize(tei)
(OUTPUT_DIR / f"{pdf.stem}.txt").write_text(text, encoding="utf-8")
for k, v in stats.items():
print(f" {k:<20} {v}")
print(f"\nErgebnisse in {OUTPUT_DIR}/")
if __name__ == "__main__":
main()