Tune LiteParse OCR defaults
Add an adaptive OCR heuristic that samples PDF text density and disables LiteParse OCR for large text-rich PDFs before the OCR path reaches timeout territory. Keep LiteParse isolated in a subprocess regardless of stale user config, remove the subprocess toggle from the settings UI, and raise the default LiteParse worker count to 2 for a safer multi-chat speedup. Update Document Query docs and focused tests for the new heuristic, mandatory isolation, and worker default.
Alessandro committed
May 30, 2026 at 19:02 UTC
9e4b2f1843b2a9ab60d15ea2548400f16194aa90
5 files changed
+352
-34
plugins/_document_query/README.md
+6
-2
@@ -8,6 +8,7 @@ timeouts and thread-safe parsers.
8
- **Strategy-pattern parsers** - MIME-type routing to dedicated parser classes
9
- **Centralized fetching** - local and HTTP(S) resources are fetched once, size-checked, then passed to parsers
10
- **LiteParse first path** - fast local parsing for PDFs and supported document/image formats, with legacy fallbacks
11
+- **Adaptive OCR** - large text-rich PDFs skip OCR automatically to avoid pathological parse times
12
- **Bounded parser execution** - sync parsers are offloaded to asyncio.to_thread and globally capped across chats
13
- **Configurable timeouts** - per-document and gather-level timeouts
14
- **Expanded format support** - PDF, HTML, text, YAML, XML, TOML, JS, TS, images, and catch-all Unstructured
@@ -29,14 +30,17 @@ See default_config.yaml for all options. Key settings:
30
| chunk_overlap | 100 | Text splitter overlap |
31
| search_threshold | 0.5 | Similarity search threshold |
32
| liteparse_enabled | true | Prefer LiteParse before legacy parser fallbacks |
32
-| liteparse_num_workers | 1 | Max LiteParse OCR workers per parser job |
33
-| liteparse_subprocess | true | Run LiteParse in a child process so native crashes fall back safely |
33
+| liteparse_num_workers | 2 | Max LiteParse OCR workers per parser job |
34
+| liteparse_ocr_auto_disable_pages | 30 | Disable OCR for text-rich PDFs at or above this effective page count |
35
| thread_offload | true | Offload sync parsers to thread pool |
36
37
LiteParse is installed into the Agent Zero framework runtime from hooks.py during
38
plugin install/startup. If installation fails, the plugin logs the error and
39
continues with the legacy parser fallbacks.
40
41
+LiteParse always runs in a child process so native parser and OCR failures stay
42
+isolated from the Web UI process.
43
+
44
## Parsers
45
46
| Parser | MIME Types | Backend |
plugins/_document_query/default_config.yaml
+5
-2
@@ -28,7 +28,10 @@ liteparse_target_pages:
28
liteparse_dpi: 150
29
liteparse_preserve_very_small_text: false
30
liteparse_output_format: text
31
-liteparse_num_workers: 1 # LiteParse defaults to CPU cores - 1; cap it for web runtime stability
32
-liteparse_subprocess: true # isolate LiteParse native runtime crashes from the Web UI process
31
+liteparse_num_workers: 2 # balanced default for OCR speed without overloading shared Web UI runtime
32
+liteparse_ocr_auto_disable: true # disable OCR automatically for large text-rich PDFs
33
+liteparse_ocr_auto_disable_pages: 30 # OCR-on runtime climbs sharply around this page count
34
+liteparse_ocr_auto_min_chars_per_page: 80
35
+liteparse_ocr_auto_sample_pages: 5
36
pdf_ocr_fallback: true # enable legacy Tesseract fallback after PyMuPDF
37
thread_offload: true # offload sync parsers to thread pool
plugins/_document_query/helpers/parsers/liteparse.py
+171
-7
@@ -6,16 +6,29 @@ import json
6
import os
7
import subprocess
8
import sys
9
+from dataclasses import dataclass
10
from pathlib import Path
11
12
from plugins._document_query.helpers.fetch import FetchedDocument
13
from .base import BaseParser
14
15
16
+DEFAULT_OCR_AUTO_DISABLE_PAGES = 30
17
+DEFAULT_OCR_AUTO_MIN_CHARS_PER_PAGE = 80
18
+DEFAULT_OCR_AUTO_SAMPLE_PAGES = 5
19
+
20
+
21
+@dataclass(frozen=True)
22
+class _PdfTextProfile:
23
+ page_count: int
24
+ sampled_pages: int
25
+ text_chars: int
26
+
27
+
28
class LiteParseParser(BaseParser):
29
"""Fast parser powered by run-llama/liteparse when available."""
30
18
- DEFAULT_NUM_WORKERS = 1
31
+ DEFAULT_NUM_WORKERS = 2
32
33
mimetypes = [
34
"application/pdf",
@@ -35,9 +48,8 @@ class LiteParseParser(BaseParser):
48
return bool(config.get("liteparse_enabled", True))
49
50
def _parse_sync(self, document: FetchedDocument, config: dict) -> str:
38
- if config.get("liteparse_subprocess", True):
39
- return self._parse_subprocess(document, config)
40
- return self._parse_in_process(document, config)
51
+ # Keep LiteParse native/OCR failures isolated from the Web UI process.
52
+ return self._parse_subprocess(document, config)
53
54
def _parse_in_process(self, document: FetchedDocument, config: dict) -> str:
55
try:
@@ -58,7 +70,7 @@ class LiteParseParser(BaseParser):
70
with document.local_file() as file_path:
71
payload = {
72
"file_path": file_path,
61
- "kwargs": self._liteparse_kwargs(config),
73
+ "kwargs": self._liteparse_kwargs(config, document, file_path),
74
}
75
env = os.environ.copy()
76
project_root = str(Path(__file__).resolve().parents[4])
@@ -104,9 +116,18 @@ class LiteParseParser(BaseParser):
116
raise ValueError("LiteParse returned no text")
117
return text
118
107
- def _liteparse_kwargs(self, config: dict) -> dict:
119
+ def _liteparse_kwargs(
120
+ self,
121
+ config: dict,
122
+ document: FetchedDocument | None = None,
123
+ file_path: str | None = None,
124
+ ) -> dict:
125
+ ocr_enabled = bool(config.get("liteparse_ocr_enabled", True))
126
+ if ocr_enabled and self._should_disable_ocr(config, document, file_path):
127
+ ocr_enabled = False
128
+
129
kwargs = {
109
- "ocr_enabled": bool(config.get("liteparse_ocr_enabled", True)),
130
+ "ocr_enabled": ocr_enabled,
131
"ocr_language": config.get("liteparse_ocr_language", "eng"),
132
"max_pages": int(config.get("liteparse_max_pages", 1000)),
133
"dpi": float(config.get("liteparse_dpi", 150)),
@@ -138,6 +159,149 @@ class LiteParseParser(BaseParser):
159
160
return kwargs
161
162
+ def _should_disable_ocr(
163
+ self,
164
+ config: dict,
165
+ document: FetchedDocument | None,
166
+ file_path: str | None,
167
+ ) -> bool:
168
+ if not bool(config.get("liteparse_ocr_auto_disable", True)):
169
+ return False
170
+ if not document or document.mimetype != "application/pdf" or not file_path:
171
+ return False
172
+
173
+ profile = _pdf_text_profile(file_path, config)
174
+ if not profile or profile.sampled_pages <= 0:
175
+ return False
176
+
177
+ effective_pages = _effective_page_budget(config, profile.page_count)
178
+ auto_disable_pages = _positive_int(
179
+ config.get("liteparse_ocr_auto_disable_pages"),
180
+ DEFAULT_OCR_AUTO_DISABLE_PAGES,
181
+ )
182
+ if effective_pages < auto_disable_pages:
183
+ return False
184
+
185
+ min_chars_per_page = _positive_int(
186
+ config.get("liteparse_ocr_auto_min_chars_per_page"),
187
+ DEFAULT_OCR_AUTO_MIN_CHARS_PER_PAGE,
188
+ )
189
+ return (profile.text_chars / profile.sampled_pages) >= min_chars_per_page
190
+
191
+
192
+def _pdf_text_profile(file_path: str, config: dict) -> _PdfTextProfile | None:
193
+ try:
194
+ import fitz
195
+ except Exception:
196
+ return None
197
+
198
+ try:
199
+ with fitz.open(file_path) as doc:
200
+ page_count = doc.page_count
201
+ if page_count <= 0:
202
+ return _PdfTextProfile(0, 0, 0)
203
+ sample_indexes = _sample_page_indexes(
204
+ page_count=page_count,
205
+ sample_pages=_positive_int(
206
+ config.get("liteparse_ocr_auto_sample_pages"),
207
+ DEFAULT_OCR_AUTO_SAMPLE_PAGES,
208
+ ),
209
+ target_pages=config.get("liteparse_target_pages"),
210
+ )
211
+ text_chars = 0
212
+ for page_index in sample_indexes:
213
+ text = doc[page_index].get_text("text") or ""
214
+ text_chars += len("".join(text.split()))
215
+ return _PdfTextProfile(
216
+ page_count=page_count,
217
+ sampled_pages=len(sample_indexes),
218
+ text_chars=text_chars,
219
+ )
220
+ except Exception:
221
+ return None
222
+
223
+
224
+def _effective_page_budget(config: dict, page_count: int) -> int:
225
+ target_pages = config.get("liteparse_target_pages")
226
+ if target_pages not in (None, ""):
227
+ parsed_target_count = _target_page_count(str(target_pages), page_count)
228
+ if parsed_target_count:
229
+ return parsed_target_count
230
+
231
+ max_pages = _positive_int(config.get("liteparse_max_pages"), 1000)
232
+ return min(max_pages, page_count)
233
+
234
+
235
+def _target_page_count(value: str, page_count: int) -> int:
236
+ pages = _target_page_numbers(value, page_count)
237
+ if pages is None:
238
+ return 0
239
+ return len(pages)
240
+
241
+
242
+def _target_page_numbers(value: str, page_count: int) -> set[int] | None:
243
+ pages: set[int] = set()
244
+ for raw_part in value.split(","):
245
+ part = raw_part.strip()
246
+ if not part:
247
+ continue
248
+ if "-" in part:
249
+ start_raw, end_raw = part.split("-", 1)
250
+ try:
251
+ start = int(start_raw.strip())
252
+ end = int(end_raw.strip())
253
+ except ValueError:
254
+ return None
255
+ if start <= 0 or end <= 0:
256
+ return None
257
+ if start > end:
258
+ start, end = end, start
259
+ pages.update(range(start, min(end, page_count) + 1))
260
+ else:
261
+ try:
262
+ page = int(part)
263
+ except ValueError:
264
+ return None
265
+ if page <= 0:
266
+ return None
267
+ if page <= page_count:
268
+ pages.add(page)
269
+ return pages
270
+
271
+
272
+def _sample_page_indexes(
273
+ page_count: int,
274
+ sample_pages: int,
275
+ target_pages: str | None = None,
276
+) -> list[int]:
277
+ if page_count <= 0 or sample_pages <= 0:
278
+ return []
279
+
280
+ candidate_indexes: list[int]
281
+ if target_pages not in (None, ""):
282
+ target_page_numbers = _target_page_numbers(str(target_pages), page_count)
283
+ if target_page_numbers:
284
+ candidate_indexes = sorted(page - 1 for page in target_page_numbers)
285
+ else:
286
+ candidate_indexes = list(range(page_count))
287
+ else:
288
+ candidate_indexes = list(range(page_count))
289
+
290
+ if len(candidate_indexes) <= sample_pages:
291
+ return candidate_indexes
292
+
293
+ anchors = [0, 1, 2, len(candidate_indexes) // 2, len(candidate_indexes) - 1]
294
+ indexes = []
295
+ seen = set()
296
+ for anchor in anchors:
297
+ index = candidate_indexes[min(max(anchor, 0), len(candidate_indexes) - 1)]
298
+ if index not in seen:
299
+ seen.add(index)
300
+ indexes.append(index)
301
+ if len(indexes) >= sample_pages:
302
+ break
303
+ return indexes
304
+
305
306
def _detect_tessdata_path() -> str:
307
env_path = os.getenv("TESSDATA_PREFIX", "")
plugins/_document_query/webui/config.html
+3
-18
@@ -28,8 +28,7 @@
28
liteparse_dpi: 150,
29
liteparse_preserve_very_small_text: false,
30
liteparse_output_format: 'text',
31
- liteparse_num_workers: 1,
32
- liteparse_subprocess: true,
31
+ liteparse_num_workers: 2,
32
pdf_ocr_fallback: true,
33
thread_offload: true,
34
},
@@ -51,7 +50,7 @@
50
this.ensureNumber('gather_timeout', 120, 1);
51
this.ensureInt('liteparse_max_pages', 1000, 1);
52
this.ensureNumber('liteparse_dpi', 150, 72);
54
- this.ensureInt('liteparse_num_workers', 1, 1);
53
+ this.ensureInt('liteparse_num_workers', 2, 1);
54
this.syncChunkOverlap();
55
},
56
ensureInt(key, fallback, min = null, max = null) {
@@ -293,7 +292,7 @@
292
</div>
293
<div class="field-control">
294
<input type="number" min="1" step="1"
296
- @change="ensureInt('liteparse_num_workers', 1, 1)"
295
+ @change="ensureInt('liteparse_num_workers', 2, 1)"
296
x-model.number="config.liteparse_num_workers" />
297
</div>
298
</div>
@@ -430,20 +429,6 @@
429
</div>
430
</div>
431
433
- <div class="field">
434
- <div class="field-label">
435
- <div class="field-title">Run LiteParse in subprocess</div>
436
- <div class="field-description">
437
- Isolate LiteParse native runtime failures from the Web UI process.
438
- </div>
439
- </div>
440
- <div class="field-control">
441
- <label class="toggle">
442
- <input type="checkbox" x-model="config.liteparse_subprocess" />
443
- <span class="toggler"></span>
444
- </label>
445
- </div>
446
- </div>
432
</div>
433
</template>
434
tests/test_document_query_plugin.py
+167
-5
@@ -10,6 +10,7 @@ from plugins._document_query.helpers.fetch import FetchedDocument, fetch_public_
10
from plugins._document_query.helpers.document_query import DocumentQueryHelper
11
from plugins._document_query.helpers.parsers.base import BaseParser
12
from plugins._document_query.helpers.parsers import get_parsers_for_mimetype
13
+from plugins._document_query.helpers.parsers import liteparse as liteparse_module
14
from plugins._document_query.helpers.parsers.liteparse import LiteParseParser
15
from plugins._document_query.helpers.parsers.text import TextParser
16
@@ -119,8 +120,9 @@ def test_default_config_bounds_liteparse_runtime_concurrency():
120
121
assert "parser_concurrency: 1" in default_config
122
assert "context_intro_chunks: 2" in default_config
122
- assert "liteparse_num_workers: 1" in default_config
123
- assert "liteparse_subprocess: true" in default_config
123
+ assert "liteparse_num_workers: 2" in default_config
124
+ assert "liteparse_ocr_auto_disable_pages: 30" in default_config
125
+ assert "liteparse_subprocess" not in default_config
126
127
128
def test_config_panel_exposes_document_query_settings():
@@ -153,11 +155,11 @@ def test_config_panel_exposes_document_query_settings():
155
"liteparse_preserve_very_small_text",
156
"liteparse_output_format",
157
"liteparse_num_workers",
156
- "liteparse_subprocess",
158
"pdf_ocr_fallback",
159
"thread_offload",
160
]:
161
assert f"config.{setting}" in config_html
162
+ assert "liteparse_subprocess" not in config_html
163
164
165
def test_document_query_thumbnail_matches_plugin_hub_limits():
@@ -173,9 +175,169 @@ def test_document_query_thumbnail_matches_plugin_hub_limits():
175
def test_liteparse_parser_caps_workers_by_default():
176
parser = LiteParseParser()
177
176
- assert parser._liteparse_kwargs({})["num_workers"] == 1
178
+ assert parser._liteparse_kwargs({})["num_workers"] == 2
179
assert parser._liteparse_kwargs({"liteparse_num_workers": "3"})["num_workers"] == 3
178
- assert parser._liteparse_kwargs({"liteparse_num_workers": ""})["num_workers"] == 1
180
+ assert parser._liteparse_kwargs({"liteparse_num_workers": ""})["num_workers"] == 2
181
+
182
+
183
+def test_liteparse_parser_always_uses_subprocess(monkeypatch):
184
+ fetched = FetchedDocument(
185
+ uri="/tmp/report.pdf",
186
+ source_uri="/tmp/report.pdf",
187
+ scheme="file",
188
+ mimetype="application/pdf",
189
+ content=b"",
190
+ local_path="/tmp/report.pdf",
191
+ )
192
+ parser = LiteParseParser()
193
+
194
+ monkeypatch.setattr(parser, "_parse_subprocess", lambda _document, _config: "ok")
195
+
196
+ def fail_in_process(_document, _config):
197
+ raise AssertionError("LiteParse must stay isolated from the Web UI process")
198
+
199
+ monkeypatch.setattr(parser, "_parse_in_process", fail_in_process)
200
+
201
+ assert parser._parse_sync(fetched, {"liteparse_subprocess": False}) == "ok"
202
+
203
+
204
+def test_liteparse_auto_disables_ocr_for_large_text_pdf(monkeypatch):
205
+ parser = LiteParseParser()
206
+ fetched = FetchedDocument(
207
+ uri="/tmp/report.pdf",
208
+ source_uri="/tmp/report.pdf",
209
+ scheme="file",
210
+ mimetype="application/pdf",
211
+ content=b"",
212
+ local_path="/tmp/report.pdf",
213
+ )
214
+ monkeypatch.setattr(
215
+ liteparse_module,
216
+ "_pdf_text_profile",
217
+ lambda _file_path, _config: liteparse_module._PdfTextProfile(
218
+ page_count=277,
219
+ sampled_pages=5,
220
+ text_chars=2500,
221
+ ),
222
+ )
223
+
224
+ kwargs = parser._liteparse_kwargs({}, fetched, "/tmp/report.pdf")
225
+
226
+ assert kwargs["ocr_enabled"] is False
227
+
228
+
229
+def test_liteparse_keeps_ocr_for_small_pdf(monkeypatch):
230
+ parser = LiteParseParser()
231
+ fetched = FetchedDocument(
232
+ uri="/tmp/bill.pdf",
233
+ source_uri="/tmp/bill.pdf",
234
+ scheme="file",
235
+ mimetype="application/pdf",
236
+ content=b"",
237
+ local_path="/tmp/bill.pdf",
238
+ )
239
+ monkeypatch.setattr(
240
+ liteparse_module,
241
+ "_pdf_text_profile",
242
+ lambda _file_path, _config: liteparse_module._PdfTextProfile(
243
+ page_count=10,
244
+ sampled_pages=5,
245
+ text_chars=2500,
246
+ ),
247
+ )
248
+
249
+ kwargs = parser._liteparse_kwargs({}, fetched, "/tmp/bill.pdf")
250
+
251
+ assert kwargs["ocr_enabled"] is True
252
+
253
+
254
+def test_liteparse_keeps_ocr_for_large_text_sparse_pdf(monkeypatch):
255
+ parser = LiteParseParser()
256
+ fetched = FetchedDocument(
257
+ uri="/tmp/scan.pdf",
258
+ source_uri="/tmp/scan.pdf",
259
+ scheme="file",
260
+ mimetype="application/pdf",
261
+ content=b"",
262
+ local_path="/tmp/scan.pdf",
263
+ )
264
+ monkeypatch.setattr(
265
+ liteparse_module,
266
+ "_pdf_text_profile",
267
+ lambda _file_path, _config: liteparse_module._PdfTextProfile(
268
+ page_count=277,
269
+ sampled_pages=5,
270
+ text_chars=20,
271
+ ),
272
+ )
273
+
274
+ kwargs = parser._liteparse_kwargs({}, fetched, "/tmp/scan.pdf")
275
+
276
+ assert kwargs["ocr_enabled"] is True
277
+
278
+
279
+def test_liteparse_respects_explicit_ocr_disabled(monkeypatch):
280
+ parser = LiteParseParser()
281
+ fetched = FetchedDocument(
282
+ uri="/tmp/bill.pdf",
283
+ source_uri="/tmp/bill.pdf",
284
+ scheme="file",
285
+ mimetype="application/pdf",
286
+ content=b"",
287
+ local_path="/tmp/bill.pdf",
288
+ )
289
+ monkeypatch.setattr(
290
+ liteparse_module,
291
+ "_pdf_text_profile",
292
+ lambda _file_path, _config: liteparse_module._PdfTextProfile(
293
+ page_count=10,
294
+ sampled_pages=5,
295
+ text_chars=0,
296
+ ),
297
+ )
298
+
299
+ kwargs = parser._liteparse_kwargs(
300
+ {"liteparse_ocr_enabled": False},
301
+ fetched,
302
+ "/tmp/bill.pdf",
303
+ )
304
+
305
+ assert kwargs["ocr_enabled"] is False
306
+
307
+
308
+def test_liteparse_target_pages_can_keep_ocr_enabled_for_large_pdf(monkeypatch):
309
+ parser = LiteParseParser()
310
+ fetched = FetchedDocument(
311
+ uri="/tmp/report.pdf",
312
+ source_uri="/tmp/report.pdf",
313
+ scheme="file",
314
+ mimetype="application/pdf",
315
+ content=b"",
316
+ local_path="/tmp/report.pdf",
317
+ )
318
+ monkeypatch.setattr(
319
+ liteparse_module,
320
+ "_pdf_text_profile",
321
+ lambda _file_path, _config: liteparse_module._PdfTextProfile(
322
+ page_count=277,
323
+ sampled_pages=5,
324
+ text_chars=2500,
325
+ ),
326
+ )
327
+
328
+ small_range = parser._liteparse_kwargs(
329
+ {"liteparse_target_pages": "1-10"},
330
+ fetched,
331
+ "/tmp/report.pdf",
332
+ )
333
+ large_range = parser._liteparse_kwargs(
334
+ {"liteparse_target_pages": "1-40"},
335
+ fetched,
336
+ "/tmp/report.pdf",
337
+ )
338
+
339
+ assert small_range["ocr_enabled"] is True
340
+ assert large_range["ocr_enabled"] is False
341
342
343
def test_query_optimize_prompt_filename_is_spelled_correctly():