File size: 9,001 Bytes
b68816f 7124acf b68816f 7124acf b68816f f07443e b68816f 7124acf b68816f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 | """The single place pipeline settings live.
β Official backend: `hybrid` at `effort="high"` β decided 2026-08-24 after all
four were measured on the same GPU, same documents (29 pages).
hybrid/high 4.85 s/page image descriptions YES β in use
hybrid/medium 1.88 s/page descriptions FORCE-DISABLED by MinerU
pipeline 2.67 s/page never analyses images at all
vlm 6.96 s/page output IDENTICAL to hybrid/high, 100%
Two findings decided it:
- `vlm` and `hybrid/high` produce the same LaTeX and the same descriptions,
but `vlm` is slower. There is no reason to pick it.
- `pipeline` is the only backend that copies the multiplication sign as a
literal `x`, which fuses `MOHH x Qty x PA` into a single token and takes
five terms with it. It is also slower than `hybrid/medium`, so it loses
on both counts.
The `high` vs `medium` cost gap across the whole BUMA corpus (6,800 pages) is
$6.89 against $2.67 β too small to trade image descriptions for.
`hybrid` and `vlm` REQUIRE a GPU. To run on a laptop without one, ask for
`pipeline` explicitly:
python -m src.knowledge_parsing.run --input data/knowledge_docs/ --backend pipeline
Determinism: SETTLED 2026-08-26. Two genuine re-parses of the same documents
produced byte-identical artifacts across all fields β including the four image
descriptions, which are the only model-written content and were the likeliest to
wobble. Frozen baselines do not drift; calibrating on this backend is safe.
Parse TIME varies (model loading), the output does not.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Literal
Backend = Literal["pipeline", "vlm", "hybrid", "mistral", "paddleocr"]
# Storage root: the repo root (src/knowledge_parsing/ -> ../../).
# Everything written out (cache/, runs/) is DATA, not code β gitignored.
ROOT = Path(__file__).resolve().parent.parent.parent
@dataclass
class PipelineConfig:
# --- changed most often ---
# `mistral` is the primary backend (2026-09-02): Mistral OCR API, no GPU,
# section recall 0.878 vs MinerU 0.829 on the BUMA fixture. `hybrid`/`vlm`/
# `pipeline` are MinerU, kept as a fallback until a final call to drop them.
backend: Backend = "mistral"
# β οΈ MinerU has NO Indonesian language code. Indonesian documents use "ch",
# whose coverage is "Chinese, English, Japanese, Chinese Traditional, LATIN"
# β and Latin script is what covers Indonesian. Passing "id" fails outright:
# ValueError: Language id not supported.
lang: str = "ch"
# --- locations ---
input_dir: Path = ROOT / "data" / "knowledge_docs"
cache_dir: Path = ROOT / "data" / "knowledge_cache" # parse output, keyed by document
runs_dir: Path = ROOT / "data" / "knowledge_runs" # normalised output + per-run manifest
# --- MinerU options ---
formula_enable: bool = True
table_enable: bool = True
# Applies to `hybrid` only, and is NOT merely fast-vs-thorough: MinerU
# force-disables image analysis at `medium`. Dropping to "medium" throws
# away image descriptions β the very reason hybrid was chosen.
effort: str = "high"
start_page: int = 0
end_page: int | None = None
# --- Mistral OCR options (backend="mistral") ---
mistral_model: str = "mistral-ocr-latest"
# include_blocks gives a typed block stream (title/text/equation/table/β¦)
# ~1:1 with MinerU's content_list β required for section chunking + headings.
mistral_include_blocks: bool = True
# bbox_annotation: a Vision-LLM description per image (the generated_description
# field). Costs an extra call per image; turn off to save spend.
mistral_describe_images: bool = True
mistral_table_format: str = "html"
# --- PaddleOCR-VL options (backend="paddleocr", added 2026-09-24) ---
# Baidu AI Studio's hosted job API. Selected by the v2 document process path
# (settings.document_parser_backend); the default backend above is unchanged,
# so the offline knowledge pipeline never reaches it unless asked. PDF and
# images only β the API takes no DOCX.
paddle_model: str = "PaddleOCR-VL-1.6"
paddle_job_url: str = "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs"
# Upper bound on waiting for the hosted job. v2 ingest has its own 10-minute
# budget around the whole document, which binds first.
paddle_poll_timeout_s: float = 900.0
# --- behaviour ---
resume: bool = True # skip documents already finished
# Keep asset METADATA but discard the bytes. The figure is still folded into
# its prose chunk with its caption, description and content-addressed id β
# only `storage_key` comes back null. For a run where the images are not
# wanted on disk, or where storage is unavailable.
no_assets: bool = False
write_debug_pdf: bool = True # _layout.pdf & _span.pdf: fastest quality check there is
# --- quality-check thresholds (see checks.py) ---
min_latex_len: int = 8 # latex shorter than this is suspected truncated
extra: dict[str, Any] = field(default_factory=dict)
def canonical_lang(self) -> str:
"""The language code MinerU will actually use.
`validate_public_ocr_lang` both validates and canonicalises: the aliases
'latin' / 'en' / 'japan' all resolve to 'ch', and unknown codes are
rejected. This is the same function that raises "Language id not
supported".
The cache fingerprint uses it too: without canonicalising, `--lang latin`
and `--lang ch` produce different cache keys for an identical parse, and
the same document gets parsed twice.
Note that only ImportError is caught (MinerU not installed). ValueError
is deliberately LET THROUGH β that one means a genuinely wrong language
code, and swallowing it would delay the failure until after the models
have loaded.
"""
try:
from mineru.utils.ocr_language import validate_public_ocr_lang
except ImportError:
return self.lang
return validate_public_ocr_lang(self.lang)
def validate(self) -> None:
"""Fail fast on a bad configuration.
Without this, a wrong language code only surfaces after ~30 seconds of
model loading β and on a large batch, after the nth document.
"""
try:
self.canonical_lang()
except ValueError as e:
raise SystemExit(
f"{e}\n"
" -> Indonesian documents use 'ch' (it covers Latin script). "
"MinerU has no 'id' code."
) from e
# The GPU precondition deliberately does NOT live here. A GPU is needed
# to PARSE, not to run: with a warm cache, `hybrid` output can be
# rebuilt on a laptop from MinerU's saved output, and that is the normal
# way to pick up a normalisation fix without paying for a re-parse.
# Checking here would refuse that. See `parse.require_gpu`, called
# immediately before MinerU is invoked.
def fingerprint(self) -> str:
"""Fingerprint of the settings that AFFECT the parse result.
Used as part of the cache key. Options that do not change the output
(resume, runs_dir, check thresholds) are deliberately excluded, so
changing them does not invalidate a cache entry that is still valid.
"""
material = {
"backend": self.backend,
"lang": self.canonical_lang(),
"formula_enable": self.formula_enable,
"table_enable": self.table_enable,
"effort": self.effort if self.backend == "hybrid" else None,
"start_page": self.start_page,
"end_page": self.end_page,
}
# Mistral settings are added ONLY on the mistral backend, so the existing
# MinerU cache keys stay byte-identical and no cached parse is invalidated.
if self.backend == "mistral":
material.update({
"mistral_model": self.mistral_model,
"mistral_include_blocks": self.mistral_include_blocks,
"mistral_describe_images": self.mistral_describe_images,
"mistral_table_format": self.mistral_table_format,
})
# Same rule for PaddleOCR: only on its own backend, so no other key moves.
if self.backend == "paddleocr":
material["paddle_model"] = self.paddle_model
payload = json.dumps(material, sort_keys=True).encode()
return hashlib.sha256(payload).hexdigest()[:12]
def to_dict(self) -> dict[str, Any]:
d = asdict(self)
for k, v in d.items():
if isinstance(v, Path):
d[k] = str(v)
d["fingerprint"] = self.fingerprint()
return d
|