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:
+121
@@ -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()
|
||||
Reference in New Issue
Block a user