rag-search-local 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. rag_search_local-1.1.0/ARCHITECTURE.md +1238 -0
  2. rag_search_local-1.1.0/CONTRIBUTING.md +172 -0
  3. rag_search_local-1.1.0/LICENSE +21 -0
  4. rag_search_local-1.1.0/MANIFEST.in +5 -0
  5. rag_search_local-1.1.0/PKG-INFO +819 -0
  6. rag_search_local-1.1.0/README.md +776 -0
  7. rag_search_local-1.1.0/pyproject.toml +96 -0
  8. rag_search_local-1.1.0/scripts/build_release.sh +89 -0
  9. rag_search_local-1.1.0/scripts/check_release.py +93 -0
  10. rag_search_local-1.1.0/scripts/cloud_setup.sh +65 -0
  11. rag_search_local-1.1.0/scripts/install.sh +173 -0
  12. rag_search_local-1.1.0/scripts/measure_lanes.py +299 -0
  13. rag_search_local-1.1.0/scripts/mine_traces.py +466 -0
  14. rag_search_local-1.1.0/scripts/release_guard.py +105 -0
  15. rag_search_local-1.1.0/scripts/run_sample.sh +83 -0
  16. rag_search_local-1.1.0/scripts/sanity_check.py +365 -0
  17. rag_search_local-1.1.0/scripts/try_readers.py +185 -0
  18. rag_search_local-1.1.0/scripts/uninstall.sh +42 -0
  19. rag_search_local-1.1.0/setup.cfg +4 -0
  20. rag_search_local-1.1.0/src/rag_search/__init__.py +7 -0
  21. rag_search_local-1.1.0/src/rag_search/__main__.py +4 -0
  22. rag_search_local-1.1.0/src/rag_search/access.py +193 -0
  23. rag_search_local-1.1.0/src/rag_search/api.py +707 -0
  24. rag_search_local-1.1.0/src/rag_search/bundle.py +500 -0
  25. rag_search_local-1.1.0/src/rag_search/catalog.py +110 -0
  26. rag_search_local-1.1.0/src/rag_search/cli.py +2874 -0
  27. rag_search_local-1.1.0/src/rag_search/client.py +197 -0
  28. rag_search_local-1.1.0/src/rag_search/config.py +209 -0
  29. rag_search_local-1.1.0/src/rag_search/core/__init__.py +0 -0
  30. rag_search_local-1.1.0/src/rag_search/core/bm25.py +60 -0
  31. rag_search_local-1.1.0/src/rag_search/core/chunker.py +222 -0
  32. rag_search_local-1.1.0/src/rag_search/core/conversion/__init__.py +7 -0
  33. rag_search_local-1.1.0/src/rag_search/core/conversion/applevision.py +79 -0
  34. rag_search_local-1.1.0/src/rag_search/core/conversion/bench.py +428 -0
  35. rag_search_local-1.1.0/src/rag_search/core/conversion/costs.py +61 -0
  36. rag_search_local-1.1.0/src/rag_search/core/conversion/degenerate.py +105 -0
  37. rag_search_local-1.1.0/src/rag_search/core/conversion/engines.py +163 -0
  38. rag_search_local-1.1.0/src/rag_search/core/conversion/estimate.py +87 -0
  39. rag_search_local-1.1.0/src/rag_search/core/conversion/gate.py +314 -0
  40. rag_search_local-1.1.0/src/rag_search/core/conversion/layer.py +237 -0
  41. rag_search_local-1.1.0/src/rag_search/core/conversion/metrics.py +236 -0
  42. rag_search_local-1.1.0/src/rag_search/core/conversion/pagecache.py +118 -0
  43. rag_search_local-1.1.0/src/rag_search/core/conversion/pageimage.py +51 -0
  44. rag_search_local-1.1.0/src/rag_search/core/conversion/pagemd.py +46 -0
  45. rag_search_local-1.1.0/src/rag_search/core/conversion/profiler.py +296 -0
  46. rag_search_local-1.1.0/src/rag_search/core/conversion/reconcile.py +165 -0
  47. rag_search_local-1.1.0/src/rag_search/core/conversion/records.py +57 -0
  48. rag_search_local-1.1.0/src/rag_search/core/conversion/repair.py +465 -0
  49. rag_search_local-1.1.0/src/rag_search/core/conversion/residue.py +154 -0
  50. rag_search_local-1.1.0/src/rag_search/core/conversion/routed.py +1002 -0
  51. rag_search_local-1.1.0/src/rag_search/core/conversion/routeharness.py +141 -0
  52. rag_search_local-1.1.0/src/rag_search/core/conversion/router.py +125 -0
  53. rag_search_local-1.1.0/src/rag_search/core/conversion/runview.py +423 -0
  54. rag_search_local-1.1.0/src/rag_search/core/conversion/scanfacts.py +182 -0
  55. rag_search_local-1.1.0/src/rag_search/core/conversion/synth.py +98 -0
  56. rag_search_local-1.1.0/src/rag_search/core/conversion/tables.py +465 -0
  57. rag_search_local-1.1.0/src/rag_search/core/conversion/tesseract.py +172 -0
  58. rag_search_local-1.1.0/src/rag_search/core/conversion/trace.py +326 -0
  59. rag_search_local-1.1.0/src/rag_search/core/conversion/validators.py +349 -0
  60. rag_search_local-1.1.0/src/rag_search/core/conversion/vlm.py +673 -0
  61. rag_search_local-1.1.0/src/rag_search/core/conversion/vlm_worker.py +142 -0
  62. rag_search_local-1.1.0/src/rag_search/core/daemon_base.py +204 -0
  63. rag_search_local-1.1.0/src/rag_search/core/diagnostics.py +382 -0
  64. rag_search_local-1.1.0/src/rag_search/core/docling_convert.py +1084 -0
  65. rag_search_local-1.1.0/src/rag_search/core/embedding.py +497 -0
  66. rag_search_local-1.1.0/src/rag_search/core/indexer.py +1603 -0
  67. rag_search_local-1.1.0/src/rag_search/core/indexer_daemon.py +527 -0
  68. rag_search_local-1.1.0/src/rag_search/core/playground.py +673 -0
  69. rag_search_local-1.1.0/src/rag_search/core/search.py +446 -0
  70. rag_search_local-1.1.0/src/rag_search/core/search_daemon.py +351 -0
  71. rag_search_local-1.1.0/src/rag_search/core/stallwatch.py +160 -0
  72. rag_search_local-1.1.0/src/rag_search/core/worker.py +161 -0
  73. rag_search_local-1.1.0/src/rag_search/descriptions.py +115 -0
  74. rag_search_local-1.1.0/src/rag_search/effective.py +258 -0
  75. rag_search_local-1.1.0/src/rag_search/grep.py +149 -0
  76. rag_search_local-1.1.0/src/rag_search/inventory.py +325 -0
  77. rag_search_local-1.1.0/src/rag_search/jobs.py +171 -0
  78. rag_search_local-1.1.0/src/rag_search/lifecycle.py +90 -0
  79. rag_search_local-1.1.0/src/rag_search/locations.py +509 -0
  80. rag_search_local-1.1.0/src/rag_search/mcp/__init__.py +6 -0
  81. rag_search_local-1.1.0/src/rag_search/mcp/__main__.py +4 -0
  82. rag_search_local-1.1.0/src/rag_search/mcp/profiles.py +44 -0
  83. rag_search_local-1.1.0/src/rag_search/mcp/server.py +267 -0
  84. rag_search_local-1.1.0/src/rag_search/model_tasks.py +624 -0
  85. rag_search_local-1.1.0/src/rag_search/models.py +800 -0
  86. rag_search_local-1.1.0/src/rag_search/paths.py +553 -0
  87. rag_search_local-1.1.0/src/rag_search/playground_runs.py +294 -0
  88. rag_search_local-1.1.0/src/rag_search/policy.py +207 -0
  89. rag_search_local-1.1.0/src/rag_search/protocol.py +74 -0
  90. rag_search_local-1.1.0/src/rag_search/publish.py +328 -0
  91. rag_search_local-1.1.0/src/rag_search/register.py +295 -0
  92. rag_search_local-1.1.0/src/rag_search/service.py +135 -0
  93. rag_search_local-1.1.0/src/rag_search/spec.py +518 -0
  94. rag_search_local-1.1.0/src/rag_search/stages.py +250 -0
  95. rag_search_local-1.1.0/src/rag_search/ui/__init__.py +5 -0
  96. rag_search_local-1.1.0/src/rag_search/ui/info.py +266 -0
  97. rag_search_local-1.1.0/src/rag_search/ui/markdown.py +189 -0
  98. rag_search_local-1.1.0/src/rag_search/ui/server.py +1104 -0
  99. rag_search_local-1.1.0/src/rag_search/ui/static/architecture.js +377 -0
  100. rag_search_local-1.1.0/src/rag_search/ui/static/collections.js +408 -0
  101. rag_search_local-1.1.0/src/rag_search/ui/static/conversion.js +402 -0
  102. rag_search_local-1.1.0/src/rag_search/ui/static/core.js +344 -0
  103. rag_search_local-1.1.0/src/rag_search/ui/static/docs/ARCHITECTURE.md +1238 -0
  104. rag_search_local-1.1.0/src/rag_search/ui/static/docs/README.md +776 -0
  105. rag_search_local-1.1.0/src/rag_search/ui/static/help.js +82 -0
  106. rag_search_local-1.1.0/src/rag_search/ui/static/index.html +61 -0
  107. rag_search_local-1.1.0/src/rag_search/ui/static/indexing.js +553 -0
  108. rag_search_local-1.1.0/src/rag_search/ui/static/models.js +344 -0
  109. rag_search_local-1.1.0/src/rag_search/ui/static/overview.js +267 -0
  110. rag_search_local-1.1.0/src/rag_search/ui/static/pipeline.js +99 -0
  111. rag_search_local-1.1.0/src/rag_search/ui/static/playground.js +702 -0
  112. rag_search_local-1.1.0/src/rag_search/ui/static/search.js +239 -0
  113. rag_search_local-1.1.0/src/rag_search/ui/static/settings.js +164 -0
  114. rag_search_local-1.1.0/src/rag_search/ui/static/style.css +413 -0
  115. rag_search_local-1.1.0/src/rag_search/ui/static/system.js +87 -0
  116. rag_search_local-1.1.0/src/rag_search_local.egg-info/PKG-INFO +819 -0
  117. rag_search_local-1.1.0/src/rag_search_local.egg-info/SOURCES.txt +236 -0
  118. rag_search_local-1.1.0/src/rag_search_local.egg-info/dependency_links.txt +1 -0
  119. rag_search_local-1.1.0/src/rag_search_local.egg-info/entry_points.txt +3 -0
  120. rag_search_local-1.1.0/src/rag_search_local.egg-info/requires.txt +22 -0
  121. rag_search_local-1.1.0/src/rag_search_local.egg-info/top_level.txt +1 -0
  122. rag_search_local-1.1.0/tests/__init__.py +0 -0
  123. rag_search_local-1.1.0/tests/corpus.py +71 -0
  124. rag_search_local-1.1.0/tests/data/README.md +46 -0
  125. rag_search_local-1.1.0/tests/data/corpus/collision/manual.docx +0 -0
  126. rag_search_local-1.1.0/tests/data/corpus/collision/manual.pdf +112 -0
  127. rag_search_local-1.1.0/tests/data/corpus/images/bitmap.bmp +0 -0
  128. rag_search_local-1.1.0/tests/data/corpus/images/fax.tif +0 -0
  129. rag_search_local-1.1.0/tests/data/corpus/images/lowres.png +0 -0
  130. rag_search_local-1.1.0/tests/data/corpus/images/phone.heic +0 -0
  131. rag_search_local-1.1.0/tests/data/corpus/images/photo-no-text.jpg +0 -0
  132. rag_search_local-1.1.0/tests/data/corpus/images/receipt-sideways.jpg +0 -0
  133. rag_search_local-1.1.0/tests/data/corpus/images/scan.png +0 -0
  134. rag_search_local-1.1.0/tests/data/corpus/images/web-picture.webp +0 -0
  135. rag_search_local-1.1.0/tests/data/corpus/office/budget.xlsx +0 -0
  136. rag_search_local-1.1.0/tests/data/corpus/office/deploy.adoc +20 -0
  137. rag_search_local-1.1.0/tests/data/corpus/office/hours.html +9 -0
  138. rag_search_local-1.1.0/tests/data/corpus/office/inventory.csv +4 -0
  139. rag_search_local-1.1.0/tests/data/corpus/office/legacy-page.htm +9 -0
  140. rag_search_local-1.1.0/tests/data/corpus/office/notes.rtf +1 -0
  141. rag_search_local-1.1.0/tests/data/corpus/office/policy.docm +0 -0
  142. rag_search_local-1.1.0/tests/data/corpus/office/report.docx +0 -0
  143. rag_search_local-1.1.0/tests/data/corpus/office/slides.pptx +0 -0
  144. rag_search_local-1.1.0/tests/data/corpus/pdf/blank-page.pdf +0 -0
  145. rag_search_local-1.1.0/tests/data/corpus/pdf/damaged.pdf +55 -0
  146. rag_search_local-1.1.0/tests/data/corpus/pdf/garbled-text-layer.pdf +68 -0
  147. rag_search_local-1.1.0/tests/data/corpus/pdf/hidden-ocr-layer.pdf +79 -0
  148. rag_search_local-1.1.0/tests/data/corpus/pdf/lowres-scan.pdf +0 -0
  149. rag_search_local-1.1.0/tests/data/corpus/pdf/many-pages.pdf +494 -0
  150. rag_search_local-1.1.0/tests/data/corpus/pdf/mixed.pdf +0 -0
  151. rag_search_local-1.1.0/tests/data/corpus/pdf/password.pdf +0 -0
  152. rag_search_local-1.1.0/tests/data/corpus/pdf/picture-on-text.pdf +85 -0
  153. rag_search_local-1.1.0/tests/data/corpus/pdf/rotated.pdf +74 -0
  154. rag_search_local-1.1.0/tests/data/corpus/pdf/scan-statement.pdf +0 -0
  155. rag_search_local-1.1.0/tests/data/corpus/pdf/scan.pdf +0 -0
  156. rag_search_local-1.1.0/tests/data/corpus/pdf/statement-pipe-artifacts.pdf +74 -0
  157. rag_search_local-1.1.0/tests/data/corpus/pdf/statement.pdf +0 -0
  158. rag_search_local-1.1.0/tests/data/corpus/pdf/table-across-pages.pdf +0 -0
  159. rag_search_local-1.1.0/tests/data/corpus/pdf/text-scan-blank.pdf +0 -0
  160. rag_search_local-1.1.0/tests/data/corpus/pdf/text.pdf +112 -0
  161. rag_search_local-1.1.0/tests/data/corpus/text/.hidden-draft.md +3 -0
  162. rag_search_local-1.1.0/tests/data/corpus/text/archive/2019/old-notes.md +5 -0
  163. rag_search_local-1.1.0/tests/data/corpus/text/empty.txt +2 -0
  164. rag_search_local-1.1.0/tests/data/corpus/text/hindi.md +5 -0
  165. rag_search_local-1.1.0/tests/data/corpus/text/notes.md +17 -0
  166. rag_search_local-1.1.0/tests/data/corpus/text/readme.txt +5 -0
  167. rag_search_local-1.1.0/tests/data/corpus/unsupported/bundle.zip +0 -0
  168. rag_search_local-1.1.0/tests/data/corpus/unsupported/legacy.doc +0 -0
  169. rag_search_local-1.1.0/tests/data/corpus/unsupported/no-extension +1 -0
  170. rag_search_local-1.1.0/tests/data/corpus/unsupported/~$report.docx +0 -0
  171. rag_search_local-1.1.0/tests/data/corpus.json +142 -0
  172. rag_search_local-1.1.0/tests/data/make_corpus.py +668 -0
  173. rag_search_local-1.1.0/tests/guard.py +45 -0
  174. rag_search_local-1.1.0/tests/helpers.py +322 -0
  175. rag_search_local-1.1.0/tests/machine/__init__.py +2 -0
  176. rag_search_local-1.1.0/tests/machine/test_real_tools.py +78 -0
  177. rag_search_local-1.1.0/tests/portable/__init__.py +9 -0
  178. rag_search_local-1.1.0/tests/portable/test_bundle_errors.py +227 -0
  179. rag_search_local-1.1.0/tests/portable/test_check_release.py +91 -0
  180. rag_search_local-1.1.0/tests/portable/test_cli_api.py +702 -0
  181. rag_search_local-1.1.0/tests/portable/test_cli_commands.py +388 -0
  182. rag_search_local-1.1.0/tests/portable/test_conv_bench.py +433 -0
  183. rag_search_local-1.1.0/tests/portable/test_conv_lanes.py +493 -0
  184. rag_search_local-1.1.0/tests/portable/test_conv_layout.py +65 -0
  185. rag_search_local-1.1.0/tests/portable/test_conv_ocrfirst.py +167 -0
  186. rag_search_local-1.1.0/tests/portable/test_conv_reconcile.py +189 -0
  187. rag_search_local-1.1.0/tests/portable/test_conv_release.py +146 -0
  188. rag_search_local-1.1.0/tests/portable/test_conv_repair.py +606 -0
  189. rag_search_local-1.1.0/tests/portable/test_conv_routed.py +578 -0
  190. rag_search_local-1.1.0/tests/portable/test_conv_vlm.py +918 -0
  191. rag_search_local-1.1.0/tests/portable/test_conversion.py +684 -0
  192. rag_search_local-1.1.0/tests/portable/test_core.py +765 -0
  193. rag_search_local-1.1.0/tests/portable/test_corpus.py +90 -0
  194. rag_search_local-1.1.0/tests/portable/test_degenerate.py +165 -0
  195. rag_search_local-1.1.0/tests/portable/test_descriptions.py +124 -0
  196. rag_search_local-1.1.0/tests/portable/test_docling_glue.py +245 -0
  197. rag_search_local-1.1.0/tests/portable/test_edges_extra.py +240 -0
  198. rag_search_local-1.1.0/tests/portable/test_effective.py +218 -0
  199. rag_search_local-1.1.0/tests/portable/test_embedding_glue.py +250 -0
  200. rag_search_local-1.1.0/tests/portable/test_enhancements.py +937 -0
  201. rag_search_local-1.1.0/tests/portable/test_hermetic.py +22 -0
  202. rag_search_local-1.1.0/tests/portable/test_indexer_daemon.py +440 -0
  203. rag_search_local-1.1.0/tests/portable/test_indexer_extra.py +258 -0
  204. rag_search_local-1.1.0/tests/portable/test_install_script.py +87 -0
  205. rag_search_local-1.1.0/tests/portable/test_inventory.py +212 -0
  206. rag_search_local-1.1.0/tests/portable/test_layer.py +184 -0
  207. rag_search_local-1.1.0/tests/portable/test_mcp.py +222 -0
  208. rag_search_local-1.1.0/tests/portable/test_mine_traces.py +184 -0
  209. rag_search_local-1.1.0/tests/portable/test_models.py +934 -0
  210. rag_search_local-1.1.0/tests/portable/test_office_formats.py +124 -0
  211. rag_search_local-1.1.0/tests/portable/test_pipeline.py +409 -0
  212. rag_search_local-1.1.0/tests/portable/test_playground.py +548 -0
  213. rag_search_local-1.1.0/tests/portable/test_playground_cli.py +162 -0
  214. rag_search_local-1.1.0/tests/portable/test_playground_runs.py +243 -0
  215. rag_search_local-1.1.0/tests/portable/test_playground_ui.py +204 -0
  216. rag_search_local-1.1.0/tests/portable/test_profile_gate.py +298 -0
  217. rag_search_local-1.1.0/tests/portable/test_publish.py +160 -0
  218. rag_search_local-1.1.0/tests/portable/test_release_guard.py +45 -0
  219. rag_search_local-1.1.0/tests/portable/test_route_harness.py +103 -0
  220. rag_search_local-1.1.0/tests/portable/test_run_views.py +225 -0
  221. rag_search_local-1.1.0/tests/portable/test_sanity_check.py +59 -0
  222. rag_search_local-1.1.0/tests/portable/test_search_daemon.py +503 -0
  223. rag_search_local-1.1.0/tests/portable/test_services_extra.py +241 -0
  224. rag_search_local-1.1.0/tests/portable/test_sources_untouched.py +139 -0
  225. rag_search_local-1.1.0/tests/portable/test_stages.py +104 -0
  226. rag_search_local-1.1.0/tests/portable/test_stallwatch.py +169 -0
  227. rag_search_local-1.1.0/tests/portable/test_table_normalize.py +134 -0
  228. rag_search_local-1.1.0/tests/portable/test_tasks_runs_extra.py +326 -0
  229. rag_search_local-1.1.0/tests/portable/test_tesseract.py +252 -0
  230. rag_search_local-1.1.0/tests/portable/test_try_readers.py +72 -0
  231. rag_search_local-1.1.0/tests/portable/test_tunables.py +245 -0
  232. rag_search_local-1.1.0/tests/portable/test_ui.py +587 -0
  233. rag_search_local-1.1.0/tests/portable/test_ui_extra.py +281 -0
  234. rag_search_local-1.1.0/tests/portable/test_worker_main.py +94 -0
  235. rag_search_local-1.1.0/tests/real/__init__.py +98 -0
  236. rag_search_local-1.1.0/tests/real/test_corpus_real.py +171 -0
  237. rag_search_local-1.1.0/tests/real/test_lanes_real.py +104 -0
@@ -0,0 +1,1238 @@
1
+ # rag-search architecture (v0.9)
2
+
3
+ ## 1. Processes
4
+
5
+ ```
6
+ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
7
+ │ rag-search │ │ rag-search- │ │ rag-search- │ │ scripts / │ front-ends: short-lived,
8
+ │ CLI │ │ mcp (Claude) │ │ mcp (other) │ │ other tools │ stdlib (+ mcp for adapters),
9
+ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ never load a model
10
+ └────────────────┴───── rag_search.api ─────┴───────┘
11
+ │ client.py (JSON lines over Unix sockets, protocol v1)
12
+ ┌────────────────────┴─────────────────────┐
13
+ ▼ ▼
14
+ ┌─────────────────────────┐ reload ┌─────────────────────────────┐
15
+ │ indexer daemon │────────────▶│ search daemon │
16
+ │ stdlib only, supervisor │ │ numpy/torch, always on │
17
+ │ start/restart/cancel/ │ │ list · grep · search · reload
18
+ │ status/follow/publish │ │ access rules per client │
19
+ └───────┬─────────────────┘ └──────────────▲──────────────┘
20
+ │ spawn (own process group) │ reads (hard-linked files)
21
+ ▼ │
22
+ ┌─────────────────────────┐ publish ┌────────────────┴─────────────┐
23
+ │ worker (docling, bge-m3)│ ─────────▶ │ serving/gen-000042/ ◀─ current
24
+ │ writes ONLY │ (indexer) │ index/<coll>/_all, markup/, │
25
+ │ indexer_workspace/ │ │ catalog.json │
26
+ └─────────────────────────┘ └───────────────────────────────┘
27
+ ```
28
+
29
+ Both daemons are single-instance (`flock` on `run/<kind>.alive`), listen on a Unix socket
30
+ (`run/<kind>.sock`, mode 0600, directory 0700) and are **always on**: they start on demand the
31
+ first time a client needs them (`client.spawn`, serialised by `run/<kind>.start.lock`, which is held until the new daemon owns its
32
+ alive-lock, so concurrent callers never start duplicates) or at login
33
+ via launchd (`rag-search service install`). `idle_exit_seconds` in `config.json` can make either exit
34
+ when idle; the default is 0 = never. Daemons, the detached dashboard, the indexing worker, Playground runs and
35
+ model tasks are all started with the data folder as their working directory (`paths.detached_start`), never the
36
+ caller's: a process whose working directory is deleted later cannot call `os.getcwd()`, and then path handling,
37
+ the process pool and `import torch` fail.
38
+
39
+ ### Indexer daemon (`core/indexer_daemon.py`, stdlib only)
40
+
41
+ * `start {mode: new|all, path, rebuild, force_md, restart}` – idempotent: while a run is active it
42
+ returns that run (`already_running`), unless `restart=true`, which kills the worker's whole
43
+ process group (SIGTERM, then SIGKILL) and starts a new run. Finished documents are skipped by
44
+ SHA-256, so a restart loses at most the document that was being embedded.
45
+ * `cancel`, `status {job_id?, history?}`, `follow {job_id?}` (streamed JSON lines, ends with
46
+ `{"event":"end"}`), `publish` (refused while a run is active).
47
+ * Each run is a **worker subprocess** (`python -m rag_search.core.worker <job-id>`) started with
48
+ `start_new_session=True`. The worker reads its spec from `jobs/<id>.json`, writes only to
49
+ `indexer_workspace/` and to `jobs/<id>.events.jsonl`; the daemon tails that file and is the only
50
+ writer of the job record. `run/index.lock` (flock, released by the kernel when a process dies)
51
+ guards the workspace even against a `rag-search index foreground` run started by hand.
52
+ * On success (`indexer.auto_publish`) the daemon calls `api.publish_and_reload`: build a generation,
53
+ switch `serving/current`, send `reload` to the search daemon (starting it if it is not running).
54
+ * On start it marks stale `running` records `interrupted` and kills orphaned workers (matched by
55
+ pid **and** command line). On shutdown it cancels the active run.
56
+ * Job statuses: `queued running succeeded partial failed cancelled interrupted` (`partial` = some
57
+ documents failed; the rest is published).
58
+
59
+ ### Search daemon (`core/search_daemon.py`)
60
+
61
+ * Binds its socket first, then loads models in a background thread: state
62
+ `starting → loading_models → loading_index → ready` (or `error`). `ping`, `list` and `grep`
63
+ answer immediately; `search` waits up to the client's `wait_s`, then replies `warming_up`.
64
+ * `reload` (also triggered by a 5 s watcher on `serving/current`, so a lost message is harmless)
65
+ loads the new generation **off to the side** and swaps it in with one pointer assignment under a
66
+ short lock. Collections whose `manifest_sha` (in `catalog.json`) and embedding model are unchanged
67
+ are **reused from memory**, not re-read or re-tokenized; only changed or new collections are
68
+ loaded (the reply lists `reused` and `loaded`). The embedding model always **follows the published
69
+ index**: a generation built with another model brings its own embedder, which is loaded off to the side and
70
+ swapped in with the generation (an engine given an explicit embedder, as in the tests, refuses such a generation:
71
+ `model_mismatch`). `reload` also applies a changed reranker setting the same way (`prepare_reranker` /
72
+ `install_reranker`); if the new reranker cannot be loaded the old one stays and the reply carries `reranker_error`.
73
+ * Enforces the per-client access rules (`policy.py`, read from `access.json`, re-read when the file
74
+ changes) for `list`, `grep` and `search`, and remembers which client names it has served
75
+ (`clients_seen` in `ping`).
76
+ * Serves searches **concurrently** (one thread per connection, no daemon-wide lock): each query
77
+ takes one consistent snapshot of (generation, embedder, reranker) from the engine, and only the
78
+ model calls themselves -- embedding the query and reranking -- are serialised (one model on one
79
+ device). BM25 scoring, the vector product and fusion of different queries overlap, so a slow
80
+ many-collection query no longer queues every other client behind it. A collection that is
81
+ loaded lazily is loaded once even when several queries need it at the same moment.
82
+
83
+ ### Front-ends
84
+
85
+ `rag_search.api` is the only place that knows which socket to call, whether to auto-start, and how
86
+ to fall back: `list` and `grep` read `serving/current` directly when the search daemon is down
87
+ (same access rules); `index status` hides restricted collections from the client asking (documents
88
+ filtered, strings that name them blanked); `index status/cancel` never start the indexer daemon just to say "idle".
89
+ The CLI and the MCP adapter are formatting layers over `api`; MCP tools run `api` calls in
90
+ `asyncio.to_thread`, and every start lock is taken and released inside one thread function, so a
91
+ cancelled MCP call cannot leave a lock held.
92
+
93
+ `list`'s per-collection reply (`catalog.list_view`, `api.list_collections`) defaults to a compact
94
+ shape -- `document_count` and a `description` instead of every document -- since the full listing
95
+ used to be sent unconditionally to whichever caller asked, MCP included, and a collection can hold
96
+ hundreds of files. `full=True` (the CLI, the dashboard, and the MCP `rag_list_collections`'s
97
+ `documents=true`) adds the per-document `documents` list back. `description` comes from
98
+ `descriptions.py` (stdlib only): `<home>/descriptions.json`, `{name: text}`, read through a
99
+ process-wide cache that re-reads it only when it changes. Nothing writes it automatically -- a
100
+ collection is just whatever folder of documents someone indexed, so there is no text to summarize
101
+ at index time -- `api.describe_collection` is the only writer (the MCP `rag_describe_collection`
102
+ tool, `rag-search describe` and the dashboard's Describe… button all call it), gated by the same
103
+ `policy.resolve_scope` a `search`/`grep` request is, so a client can only describe a collection it
104
+ is authorised to see (the administrator's `cli` may also describe one not published yet).
105
+
106
+ **Small metadata files** (`access.json`, `descriptions.json`, `locations.json`) share one set of
107
+ helpers in `paths.py`: `write_json_atomic` (temp file + `os.replace`, optional mode -- 0600 for
108
+ `access.json`), `file_lock` (an exclusive `flock` on `.<name>.lock` held across a whole
109
+ read-modify-write, so two concurrent `rag-search access …` commands cannot lose each other's
110
+ change) and `CachedFile` (re-parse only when mtime/size/inode change; `policy.AccessStore` is one).
111
+ A path a person types or pastes (a location's folder, an index target, an export or import file, a
112
+ Playground source) goes through `paths.pasted_path` first: one pair of surrounding quotes and shell escapes
113
+ are shell syntax, not part of the name; spaces, `@` and the like are ordinary characters. `locations.add`
114
+ takes the folder's own name when no collection name is given (`paths.name_from_folder`), and a path put in
115
+ the name field is taken as the folder.
116
+ Collection names are resolved in one place, `catalog.canonical_name`: exact match first, else
117
+ case-insensitive, against every collection rag-search knows (published, docs-folder folders,
118
+ locations, workspace). `policy.resolve_scope` matches the same way and always returns the real
119
+ spelling, de-duplicated -- a request naming a collection twice searches it once.
120
+
121
+ **Web dashboard** (`ui/`, stdlib only, `rag-search ui`) is a third front-end over `api`, acting as `cli`.
122
+ `ui/server.py` is a `ThreadingHTTPServer` on 127.0.0.1 (default port 8765): a sampler thread polls
123
+ `api.daemon_status` and `api.index_status` every second (5 s when nobody is watching) and the catalog
124
+ and access rules every 2 s, and pushes changes to browsers as Server-Sent Events (`/api/events`; the
125
+ page falls back to polling `/api/state`). Actions are POSTs that call the same `api` functions as the
126
+ CLI (`index/start|cancel|publish`, `access`, `describe`, `daemon`, `search`, `grep`, and `collection/add-location|import|export|delete`
127
+ + `location/remove`, where delete and remove require the collection's name typed back as `confirm`); an export's file is
128
+ offered for download through `GET /api/download?id=` with an id the server minted for that export (an in-memory
129
+ registry of the last 50; never an arbitrary path); `GET /api/collection/info?name=`
130
+ serves the expanded row of the Collections tab from `inventory.collection_info` (the same as `rag-search collection
131
+ info`), fetched only when a row is opened -- never in the polling loop, since it walks the collection's source and
132
+ workspace folders -- and refreshed when the generation or the indexing run changes; `--read-only` refuses all but
133
+ search, grep and doctor. Its own searches carry `origin: "ui"` so the search daemon does not record
134
+ "viewing as agent" as that agent having connected. Doctor runs in a child process, so the dashboard never loads
135
+ a model. Access control: loopback-only, `Host` must be loopback:port, POST must be same-origin
136
+ `application/json` (body ≤ 1 MB), and every request except `/api/ping` needs the token from
137
+ `run/ui.token` (0600) as an HttpOnly SameSite=Strict cookie or `X-RagSearch-Token`; a CSP forbids
138
+ external resources. `run/ui.pid` and `run/ui.port` belong to `--detach`.
139
+ The pages are plain HTML/CSS/JS in `ui/static/` (no build step; views register in `core.js`).
140
+ Live areas are updated in place by `patch()` (`core.js`): new content is merged into the existing nodes.
141
+ A `<details>` section keeps the state the user gave it across those updates unless the view passes an
142
+ `open` property, in which case the view owns the state.
143
+ Everything on the Architecture tab comes from `ui/info.py`, which reads the constants in `spec.py` that
144
+ the engine itself imports (RRF k, BM25 k1/b, pool sizes, limits) plus the effective chunking and model
145
+ runtime settings, so the diagrams cannot drift. The Indexing tab's *Conversion* card, branch strips,
146
+ document drawer (page grid, page detail, source-page image) and the Collections tab's *Conversion*
147
+ section come from `ui/static/conversion.js` (colour = branch, GPU/CPU chips = where it runs). The system, indexing, conversion and search pictures are hand-drawn SVG
148
+ built with the DOM in `architecture.js` (no library, nothing external, colours from the theme variables);
149
+ the daemon boxes carry live status dots; the
150
+ field lists of `nodes.json` / `index.meta.json` / `catalog.json` there are checked against real files by
151
+ the tests. The Help tab renders the README and this file (copied into `ui/static/docs/` by
152
+ `build_release.sh`, checked for drift by a test) with a small escaping Markdown renderer, and builds the
153
+ command reference from the argparse tree.
154
+
155
+ ## 2. Data folder
156
+
157
+ ```
158
+ <home>/ $RAG_SEARCH_HOME or ~/Library/Application Support/rag-search
159
+ config.json optional settings
160
+ access.json collections restricted to specific clients (written by `rag-search access` only)
161
+ descriptions.json short per-collection descriptions ({name: text}; written only through
162
+ api.describe_collection)
163
+ locations.json registered source locations ({name: folder}; `rag-search location`): the
164
+ only place documents come from -- there is no built-in documents folder
165
+ .<file>.lock read-modify-write locks of the three files above
166
+ indexer_workspace/ written by the worker (and by collection import/delete, under index.lock)
167
+ markup/<coll>/<doc>.md page-annotated Markdown (+ .md.sha256 of the source)
168
+ markup/<coll>/<doc>.trace.json conversion trace: one record per page (branch, outcome, profile,
169
+ docling confidence), see 5.1.1; removed together with its Markdown
170
+ index/<coll>/<doc>/ nodes.json embeddings.npy index.meta.json (written last)
171
+ outcome.json instead, for a document that cannot be indexed (see Freshness)
172
+ index/<coll>/_all/ merged: nodes.json embeddings.npy merge.manifest.json
173
+ index/<coll>/collection.origin.json only in an imported collection (see 5.5)
174
+ serving/
175
+ gen-000041/ gen-000042/ immutable published generations (last 3 kept)
176
+ catalog.json generation, content_sha, model, collections + documents
177
+ index/<coll>/_all/… hard links into the workspace (copy fallback)
178
+ markup/…
179
+ current -> gen-000042 symlink switched atomically (os.replace)
180
+ run/ 0700: <kind>.sock .alive .start.lock .pid .log, index.lock,
181
+ ui.token (0600) ui.pid ui.port (dashboard)
182
+ jobs/ <id>.json (record) <id>.events.jsonl <id>.log
183
+ ```
184
+
185
+ **Publishing** (`publish.py`) collects every workspace collection with a complete `_all/` index,
186
+ refuses to mix embedding models, computes a `content_sha` (no-op if it equals the live one), builds
187
+ `.tmp-gen-N`, renames it to `gen-N` and switches `current`. Hard links make it O(files); a running
188
+ search daemon keeps reading the old generation's files until it swaps. `rollback` re-points
189
+ `current` at the previous generation.
190
+
191
+ **Freshness** – a document is re-indexed when its source SHA-256, the chunk parameters,
192
+ `CHUNKER_VERSION`, `TOKENIZER_VERSION`, the index format or the model differ from
193
+ `index.meta.json`; the Markdown is reused only when the `.md.sha256` sidecar matches. An unchanged
194
+ document found at a new path (a location registered again where the folder now is) keeps its index and gets its `src_path` updated.
195
+ Merging concatenates stored embeddings; nothing is re-embedded.
196
+ A document that **cannot** be indexed for a reason of its own gets the same treatment: `outcome.json` in its
197
+ index folder records the source SHA-256, the conversion settings (`convert_profile`) and the reason, and the
198
+ next update run does not convert it -- only the checksum is computed. Remembered: a password-protected or
199
+ encrypted PDF, a PDF whose bytes cannot be opened at all (`damaged_pdf_reason`; a copy that syncs completely later
200
+ has another checksum), and "no text" when every reader had its turn. Never remembered: anything that may pass by
201
+ itself (a cloud file that could not be fetched, a timeout, a crash, a "no text" where the document reader was not
202
+ used). A changed file, a changed conversion setting, `--force-md` ("re-convert to Markdown") or a complete run
203
+ (`index all`, `--rebuild`) tries again; success or a deleted source removes the record.
204
+ **How a remembered document is reported (0.9.28).** The run that finds the failure reports it in `errors` /
205
+ `no_text`. An update run after that lists it under `known` (document status `known`, "not tried again", with the
206
+ reason and what it was), **outside** `errors`, so the run ends `succeeded` when nothing new failed; until 0.9.27 it
207
+ was put into `errors` again and every update run of a folder with one protected PDF ended `partial`. A file left
208
+ out because another file has its document name (`a.pdf` next to `a.jpg`) is handled the same way: the names a run
209
+ reported are kept in `left_out.json` in the workspace, an update run lists them as `known`, a complete run reports
210
+ them again, and a renamed or deleted file drops out. `not_retried` in the summary is the number of `known`
211
+ documents. In the dashboard's Documents list the filter **Skipped** shows everything a run did not convert, each
212
+ with its reason: unchanged, not tried again, unsupported format. Unsupported formats never reach this step: they are
213
+ recognised by extension when the folder is listed and are not opened at all. A document whose source was deleted
214
+ loses its Markdown and index (`prune_orphans`, see 5.1) -- but only when the run read its whole
215
+ collection and the collection's folder could be read; an unreachable folder changes nothing.
216
+ `index.meta.json` also records `model_revision`, the commit of the model's weights in the local
217
+ Hugging Face cache: not part of the freshness key (advisory), but carried into `catalog.json` and
218
+ collection exports and compared on import.
219
+
220
+ ## 3. Wire protocol (`protocol.py`)
221
+
222
+ One request per connection, newline-delimited JSON:
223
+
224
+ ```
225
+ → {"v":1, "client":"claude", "action":"search", "query":"…", "top_k":5, "collections":[…], "wait_s":40}
226
+ ← {"ok":true, "result":{…}} # single reply
227
+ ← {"event":"progress", …} … {"event":"end", …} # streaming actions (follow)
228
+ ← {"ok":false, "code":"forbidden", "error":"…"}
229
+ ```
230
+
231
+ `search`, `grep` and `list` results, `index status` and the search daemon's `ping` carry timings,
232
+ sizes and warm-up/memory figures (see the README table "What each command tells you about time and
233
+ size"); per-document timings travel as `doc` events in `jobs/<id>.events.jsonl` and in `follow`.
234
+ `list` also takes an optional `full` flag (default false): false returns `document_count` +
235
+ `description` per collection, true adds the per-document `documents` list (see "Front-ends" above).
236
+
237
+ Codes: `bad_request protocol_mismatch warming_up unavailable forbidden model_error model_mismatch busy
238
+ internal`. (`forbidden` is no longer sent for collections.) `PROTOCOL_VERSION` is bumped on incompatible changes; a daemon rejects newer clients
239
+ with `protocol_mismatch` and tells them to upgrade. The `client` field is the host identity
240
+ (`claude`, or any other valid name; `cli` = administrator; else `unknown`) that selects the
241
+ collections it may use. The MCP adapter fixes it from `--profile NAME`; it is declared, not authenticated.
242
+
243
+ ## 4. Layering (enforced by tests)
244
+
245
+ | Layer | Modules | May import |
246
+ |---|---|---|
247
+ | light (stdlib) | `paths config policy access descriptions locations bundle lifecycle inventory spec protocol client jobs publish catalog grep api service register cli models model_tasks`, `ui/*`, `core/daemon_base`, `core/indexer_daemon`, `core/conversion/{trace,costs,router,profiler,records,runview,estimate,pageimage}` | stdlib only (`profiler` and `pageimage` import pypdfium2 / Pillow inside functions) |
248
+ | core (heavy) | `core/{bm25,chunker,embedding,indexer,search,search_daemon,worker,docling_convert,diagnostics,playground}` | numpy, torch, docling (lazily) |
249
+ | adapter | `mcp/*` | the `mcp` package, light layer |
250
+
251
+ `tests` check that importing the light modules never pulls in numpy/torch/docling/mcp, that
252
+ only `rag_search/mcp/` imports `mcp`, and that the adapter can neither manage access nor reach
253
+ collection management (`bundle`, `lifecycle`, location changes). The adapter is installed with the
254
+ package (a dependency since 1.1; `rag-search register` adds it to Claude).
255
+
256
+ ## 5. Indexing and search flows
257
+
258
+ ### 5.0 The numbered pipeline (`stages.py`)
259
+
260
+ One numbered list of stages describes a document's way into the index and a query's way through it. The
261
+ numbers are the same everywhere: the progress events of a run (`stage` events carry `id`), the indexer's
262
+ log lines ("3 Convert started", "2 Fingerprint unchanged: skipped"), `rag-search index` output, the
263
+ Architecture tab's diagram, the Indexing tab ("Settings in effect, by pipeline stage"), the Settings tab
264
+ (one card per stage; the steps 3.1-3.5 are cards inside 3 Convert, and 3.2 Read has one card for the router and one
265
+ for each lane), the page traces, the Playground and this document.
266
+ `stages.py` (stdlib only) is the registry: each `Stage` has its number, key, name, scope, where it runs, a
267
+ one-line description, the settings it owns (`section.key` of `config.json`, the keys the Settings tab edits)
268
+ and its environment-only variables. A stage whose settings belong to several parts lists them as `groups`
269
+ (`stages.Group`: id, name, what it does and what else its settings reach): 3.2 Read has `3.2` (the router: routing,
270
+ pipeline), `3.2a` (docling: table mode, PDF backend, batch, timeout, text-layer fill, hand-over to the reader), `3.2b`
271
+ (OCR first, OCR mode, engine, languages; the Tesseract variables), `3.2c` (residue regions) and `3.2d` (document
272
+ reader switch, model, memory limit; its page timeout and backend variables). `/api/pipeline` carries the groups, and
273
+ the Settings tab, the Indexing tab's "Settings in effect" and a Playground experiment's settings all draw one block
274
+ per group, so it is plain which setting acts on which lane. `tests/test_stages.py` checks that every tunable belongs
275
+ to exactly one stage, that every setting and variable of a grouped stage is in exactly one group, that every environment variable listed is really read, and that the browser's copy of the keys
276
+ (`ui/static/pipeline.js`) equals the registry. Conversion is not a side branch: 3 Convert is a stage like
277
+ the others, with its steps 3.1-3.5 numbered inside it.
278
+
279
+ Indexing (one run; 3.2 has four lanes, chosen per page by the router: 3.2a docling on the text layer, 3.2b
280
+ OCR for a clean scan, 3.2c docling on the text layer plus the document reader on its pictures and regions, 3.2d
281
+ the document reader for every other scan and image; a page a cheaper lane doubts goes on to 3.2d):
282
+
283
+ | Stage | Name | Runs on | Once per | What it does | Settings it owns (`section.key`) |
284
+ |---|---|---|---|---|---|
285
+ | 1 | Discover | CPU | run | list the files of every collection (registered folders, imports); formats the pipeline cannot read are skipped and counted | – |
286
+ | 2 | Fingerprint | CPU | document | SHA-256 of the file plus the chunk, model and conversion settings; unchanged documents are skipped in seconds | – |
287
+ | 3 | Convert | CPU+GPU | document | turn the file into page-marked Markdown, page by page, through the steps below; several documents are converted side by side when the machine has the memory for it; a conversion process that goes silent is stopped and its document reported, so one stuck document never holds the run | `indexer.jobs`, `indexer.stall_timeout` |
288
+ | 3.1 | Profile | CPU | document | look at every page once: text layer, scan or photo, pictures, ink, resolution, script | – |
289
+ | 3.2 | Read | CPU+GPU | document | each page goes down the lane it needs: 3.2a docling on the text layer (and Office files), 3.2b OCR for a clean scan (docling's OCR, or Tesseract for a skewed page and image files), 3.2c docling on the text layer plus the document reader on its pictures and regions, 3.2d the document reader (a vision model) for every other scan, photo and image; a page a cheaper lane doubts goes on to 3.2d | `indexer.routing`, `indexer.ocr`, `indexer.ocr_engine`, `indexer.ocr_lang`, `indexer.table_mode`, `indexer.pdf_backend`, `indexer.pipeline`, `indexer.vlm`, `indexer.ocr_first`, `indexer.residue`, `indexer.escalate_digital`, `indexer.layer_fill`, `models.reader`, `models.memory_limit_gb`, `indexer.docling_batch`, `indexer.doc_timeout`; env `RAG_SEARCH_THREADS`, env `RAG_SEARCH_VLM_PAGE_TIMEOUT`, env `RAG_SEARCH_VLM_FREE_GB`, env `RAG_SEARCH_VLM_BACKEND`, env `RAG_SEARCH_TESSERACT`, env `RAG_SEARCH_TESSERACT_LANG` |
290
+ | 3.3 | Gate | CPU | document | deterministic checks on every page: coverage, script, tables, resolution, running balances and totals, runaway output | – |
291
+ | 3.4 | Repair (optional) | GPU | document | a table cell that breaks the arithmetic is cut out, read again and replaced only when a second, independent reader and the arithmetic agree | `indexer.repair`, `models.repair`; env `RAG_SEARCH_REPAIR_SECOND` |
292
+ | 3.5 | Reconcile | CPU | document | a table that runs across a page break is joined (the continuation gets the header) and checked across the break | – |
293
+ | 4 | Chunk | CPU | document | split the Markdown into passages that never cross a page; tables and code stay whole where they fit | `indexer.chunk_size`, `indexer.chunk_overlap` |
294
+ | 5 | Embed | GPU | run | turn every new passage into a vector; the model is loaded once per run, after all documents are converted | `models.embedding`, `models.embed_batch`, `models.max_seq`, `models.dtype`, `models.device` |
295
+ | 6 | Write | CPU | document | nodes.json and embeddings.npy, then index.meta.json last, so a half-written document is never taken for a finished one | – |
296
+ | 7 | Merge | CPU | collection | concatenate the documents' vectors into the collection's index for search (nothing is re-embedded) | – |
297
+ | 8 | Publish | CPU | collection | hard-link the new generation, switch the "current" symlink, tell the search daemon to reload; the last three generations are kept | `indexer.auto_publish` |
298
+
299
+ Search (one query):
300
+
301
+ | Stage | Name | Runs on | Once per | What it does | Settings it owns (`section.key`) |
302
+ |---|---|---|---|---|---|
303
+ | S1 | Access | CPU | query | which collections this client may search; an unknown name is refused like a typo | – |
304
+ | S2 | Keyword | CPU | query | BM25 over the chunk text: exact terms, identifiers, versions; the pool size below applies to both retrievers | `search.retrieval_pool` |
305
+ | S3 | Vectors | GPU | query | cosine similarity of the query vector with every passage vector; the models are loaded when the search daemon starts | `search.prewarm` |
306
+ | S4 | Fuse | CPU | query | reciprocal rank fusion of the two lists into one pool | `search.rrf_k`, `search.stages` |
307
+ | S5 | Rerank (optional) | GPU | query | a cross-encoder reads query and passage together and re-orders the best candidates | `models.reranker`, `search.rerank_pool`, `models.rerank_batch`, `models.rerank_max_len` |
308
+ | S6 | Top k | CPU | query | the best passages with page, heading, snippet and score | `search.top_k` |
309
+
310
+ **Settings really in effect (`effective.py`).** `config.json` holds the defaults; an environment variable the
311
+ daemon was started with wins over it (`settings_env`: an actual environment variable is never overwritten, 0 /
312
+ blank in the file means "no override"). `effective.resolve(cfg, env)` computes, per stage and per setting, the
313
+ value the code will read, where it comes from (default, `config.json` or environment) and which function reads
314
+ it (`READ_BY`), using the same parsers as the pipeline (`docling_convert.convert_settings`, `vlm.mode`,
315
+ `repair.mode`, ...), so what the dashboard shows cannot differ from what a run does. `GET /api/pipeline`
316
+ resolves the indexing stages with the environment of the indexer daemon and the search stages with that of
317
+ the search daemon (what each really runs with, reported by its ping as `env_overrides`). The Indexing,
318
+ Settings, Architecture and Playground tabs and `rag-search playground settings` all render this one answer.
319
+
320
+ ### 5.1 Indexing flow (worker process, `core/indexer.py`)
321
+
322
+ ```
323
+ <location folder>/<file> pdf docx docm rtf pptx xlsx html csv adoc md txt png jpg tif bmp webp
324
+ (a registered location: its whole tree is one collection)
325
+ │ plan (locations.plan_scan): refuses to run while locations.json is unreadable; every
326
+ │ source folder is probed for reachability first (in a thread, 10 s bound; a listing
327
+ │ that fails is retried within it, for cloud-storage folders that answer late); one that is
328
+ │ missing, cannot be listed, is completely empty while its collections have an index
329
+ │ (an unmounted mount point), or has a sub-folder the walk could not open, is *frozen*
330
+ │ -- not pruned, not re-merged. A run over everything, or a whole collection, *covers* it.
331
+ │ scan: hidden files and ~$ lock files skipped; two files that map
332
+ │ to the same document name (a.pdf + a.docx) -> only one is indexed (the one already
333
+ │ indexed under that name, else the first), the other is reported -- also when the one
334
+ │ already indexed is not part of this run (a single-file run cannot replace it)
335
+ │ prune: for every covered collection, documents not found lose their Markdown and
336
+ │ per-document index (files removed one by one: a.pdf + a/b.pdf nest b inside a;
337
+ │ compared by path and by file identity, so a case-only folder rename on a
338
+ │ case-insensitive disk is not mistaken for a deletion)
339
+ ▼
340
+ 1 DISCOVER is the plan and scan above (it runs once per run); the boxes below are 2-7.
341
+ ┌─ PHASE 1 · per document · parallel (`indexer.jobs` processes) · watched: a process that goes ─┐
342
+ │ silent is stopped, its document reported, the others go on (stallwatch.py, section 7) │
343
+ │ │
344
+ │ 2 FINGERPRINT: SHA-256 of the source file │
345
+ │ fresh? same source SHA + chunk size/overlap + CHUNKER/TOKENIZER version + index format │
346
+ │ + embedding model + conversion settings, as recorded in index.meta.json ─yes▶ SKIP │
347
+ │ 3.1 PROFILE pages (core/conversion/profiler.py, pypdfium2 only, 120 s budget): characters, │
348
+ │ picture cover, script, dpi per page -> a branch + reason per page (5.1.1); skipped when │
349
+ │ the Markdown and its trace are current │
350
+ │ 3 CONVERT -> markup/<coll>/<doc>.md (3.2 Read, 3.3 Gate, 3.4 Repair, 3.5 Reconcile) │
351
+ │ PDFs and image files page by page, one lane per page (5.1.1): a text layer -> docling │
352
+ │ without forced OCR; a scan -> the document reader (or OCR first for a clean one, or │
353
+ │ docling OCR when there is no reader); pictures on a text page -> the document reader. │
354
+ │ Office / HTML files whole, by docling (layout + TableFormer; pypdfium2 backend). │
355
+ │ converter kept per process, threads shared, RAG_SEARCH_DOC_TIMEOUT per docling call; │
356
+ │ every reader call is announced (`step` event) and every page reported when it is done; │
357
+ │ <!-- page N --> marks │
358
+ │ (formats without pages become page 1; .md/.txt are read as text, whatever encoding; │
359
+ │ an .md is reused when its .md.sha256 sidecar has the same source SHA + settings) │
360
+ │ no text at all (a photo, a blank page) -> "skipped: no text", not a failure; │
361
+ │ a password-protected / encrypted PDF fails, with that reason in the message │
362
+ │ 4 CHUNK (pure stdlib, core/chunker.py) │
363
+ │ split at the page markers ─▶ blocks (paragraphs; code fences and tables kept whole) │
364
+ │ ─▶ oversized blocks are split: tables by rows with the header row repeated, │
365
+ │ prose by sentences, then by words │
366
+ │ ─▶ greedy packing to ~512 estimated tokens with ~64 tokens of block-level overlap │
367
+ │ every chunk carries: page, nearest heading, file, collection, source path │
368
+ │ 6 WRITE nodes.json (index.meta.json is removed first, so a half-done document is never │
369
+ │ mistaken for a complete one) │
370
+ └──────────────────────────────────────────────┬────────────────────────────────────────────────┘
371
+ ▼
372
+ ┌─ PHASE 2 · embeddings · one process · model loaded once ──────────────────────────────────────┐
373
+ │ │
374
+ │5 chunk texts ─▶ BAAI/bge-m3 (SentenceTransformer; fp16 on mps/cuda; max_seq 1024; batch 32) │
375
+ │ ─▶ one dense vector per chunk (1024 dimensions), L2-normalised │
376
+ │ ─▶ embeddings.npy (float32) │
377
+ │6 then write index.meta.json LAST: source SHA, chunk params, chunker/tokenizer versions, │
378
+ │ model (+ weights commit), dim, chunk count, timestamp ==> marks the document as complete │
379
+ └──────────────────────────────────────────────┬────────────────────────────────────────────────┘
380
+ ▼
381
+ ┌─ PHASE 3 · merge per collection · cheap · nothing is re-embedded ─────────────────────────────┐
382
+ │ │
383
+ │7 concatenate nodes + embeddings of all COMPLETE documents whose source file still exists │
384
+ │ ─▶ index/<coll>/_all/{ nodes.json, embeddings.npy, merge.manifest.json } │
385
+ │ manifest_sha = hash of the member documents; unchanged manifest = merge skipped │
386
+ │ imported and frozen collections are not merged (left exactly as they are); a generated │
387
+ │ collection left with no documents disappears from the workspace │
388
+ └──────────────────────────────────────────────┬────────────────────────────────────────────────┘
389
+ ▼
390
+ 8 PUBLISH indexer daemon: publish -> serving/gen-N (hard links) -> `reload` to the search daemon,
391
+ which reuses in-memory collections whose manifest_sha is unchanged and loads the rest
392
+
393
+ The keyword index is NOT stored on disk. The search daemon builds BM25 in memory when it loads a
394
+ collection: tokens = lower-cased words; compound tokens ("svm-name", "9.16.1", "a/b") are kept
395
+ whole AND split into parts; postings + idf per term; Okapi BM25 with k1 = 1.5, b = 0.75.
396
+ ```
397
+
398
+ #### 5.1.1 Conversion tracking (`core/conversion/`, phase P0 of the conversion plan)
399
+
400
+ Every converted document gets a *conversion record*, in one vocabulary shared by the engine, the CLI,
401
+ the HTTP API and the dashboard (design: `docs/design/document-conversion-plan.md`):
402
+
403
+ * a page takes one **branch** -- `copy` (.md/.txt), `office` (Office/HTML; `.rtf` is read by `docling_convert.rtf_to_markdown` with the standard library, `.docm` as the `.docx` it is -- docling needs LibreOffice for RTF, and neither is allowed to need a tool that is not already installed), `digital` (PDF page with
404
+ a text layer), `raster` (scanned page or one without a usable text layer), `image` (image file or
405
+ TIFF frame), `embedded`, `fallback`, `cached`, `unknown` (not profiled) -- decided by
406
+ `router.decide(kind, page_profile)`, which also returns the reason; and ends with one **outcome**:
407
+ `pass`, `repaired`, `low`, `no_text`, `error`.
408
+ * **Routing (phase P2, `RAG_SEARCH_ROUTING=pages`, the default)**: a PDF with a usable profile is read
409
+ page by page (`conversion/routed.py`). `digital` pages go to docling *without* forced OCR (OCR only for
410
+ pictures), `raster` / `unknown` pages (scans, garbled text layers, unprofiled pages) to the **document
411
+ VLM** (5.1.3; branch `raster`) or, when it is off, not installed, short of memory or fails on the page,
412
+ to docling with full-page OCR (branch `fallback`, the reason is in the page's `note`), blank pages (no
413
+ text, almost nothing darker than the ground **and almost nothing lighter**: since 0.9.28 a slide, a screenshot in
414
+ dark mode or a photograph with light writing on a dark ground is no longer taken for a blank page) are not read
415
+ at all. docling reads pages in runs of consecutive
416
+ pages of one kind via its `page_range` (at most 20 pages per call); the document reader reads **one page per
417
+ call**, so each of its pages is stored, gated and reported the moment it is read. The OCR setting only decides
418
+ whether OCR is on or off. Image files are read page by page by the document VLM (5.1.3); Office/HTML
419
+ files and Markdown are converted whole as before,
420
+ and so is any PDF whose profile failed, when `RAG_SEARCH_DOCLING_PYTHON` is set, or with
421
+ `RAG_SEARCH_ROUTING=document` (the escape hatch; also `rag-search config set indexer.routing document`).
422
+ **If routing fails for a document (an exception from a reader, a docling that ignores `page_range`) it
423
+ is converted whole and the trace says so** -- routing can degrade, it cannot fail a document.
424
+ `convert_range` handles docling versions that keep or renumber the original page numbers of a range.
425
+ `CONVERT_VERSION` is `c4` and the conversion profile includes `route=`, so the upgrade re-converts
426
+ everything once, and switching the setting converts again.
427
+ * **Profile** (`profiler.py`): per page characters, text-layer quality, picture coverage, rotation,
428
+ script, and for pages without text a small greyscale render (36 dpi) from which `ink` (the share of
429
+ clearly dark pixels) and a content `hash` come; text pages are hashed from their text layer and size.
430
+ The profile is the page's identity for the cache and the source of the blank-page rule.
431
+ What a review of the profiler and the gate corrected in 0.9.29 (each found on generated pages, most confirmed on
432
+ the pages of the full run):
433
+ * **Where a picture is.** The boxes of large pictures (`big_pics`, cut out for the document reader in lane c) and
434
+ the text rectangles of the residue finder were taken from the page's own coordinates, which ignore `/Rotate`
435
+ and the origin of the page box: on a rotated page the reader was handed another part of the page.
436
+ `profiler.page_box` maps them with PDFium to the page as it is displayed; a picture hanging over the edge
437
+ counts for the part on the page.
438
+ * **Whose text it is.** `hidden_ocr_layer` meant "text over a full-page picture". Of 556 such pages in the run
439
+ 506 were born-digital pages on a background picture (statements on a letterhead, web pages printed with their
440
+ backdrop), and lost the comparison with their own text layer for it. It now needs **invisible** text (text
441
+ render mode 3 or 7, which is how a scanner writes its OCR): 50 pages. Those are read from that layer as before
442
+ and get `plausibility` and `column_types` from the gate, because the layer is an OCR reading nobody else has
443
+ seen (with `RAG_SEARCH_ESCALATE_DIGITAL=auto` a failure sends the page to the document reader).
444
+ * **Which text layer is garbled** (`docling_convert._page_text_ok`; a garbled layer sends the page to the
445
+ document reader). Three kinds of sound layers were called garbled, 81 of the run's 94 such pages: long tokens
446
+ that are values, not words run together (UUIDs, identifiers with `_` and `:`, rows of figures: only long runs
447
+ of letters count now, and none in a script written without spaces); bullets and icons from a symbol font
448
+ (one or two private-use or control codes standing alone; damage inside words and pages set in a symbol font
449
+ still count); and invisible format characters (zero-width spaces, joiners). Those pages keep their exact
450
+ text instead of a model's reading of their identifiers.
451
+ * **Image files.** A picture with a transparent ground (a PNG without a background) reached every reader as
452
+ black on black; `profiler.opaque` puts it on white (on black when its content is light), for the ink figure,
453
+ the document reader, Tesseract and Apple Vision (which now also gets the picture upright). Only a TIFF has
454
+ pages: the second frame of a phone's JPEG (a depth or gain map) and the frames of an animation are not read
455
+ as page 2. A resolution tag with two values counts by the coarser one (a fax).
456
+ * **Skew** (`scanfacts`): the search went to 6 degrees while pages up to 8 are straightened for Tesseract, so
457
+ a page at 8 or 12 degrees measured 5 or 6 and passed as straightenable. It goes to 10.
458
+ * **Gate.** The script of a page's text layer is not held against the reading when that layer is garbled (a
459
+ broken font map can come out as Greek letters). A Tesseract last resort is kept only when its text is words
460
+ (`plausibility`, `script`): on a page in a script Tesseract was not given it writes letters, not words.
461
+ "No text" for a page served from the page cache names the reader that read it (it said no reader had, and
462
+ the file was tried again on every update run).
463
+ * **Page cache** (`pagecache.py`, `indexer_workspace/page_cache/<hh>/<key>.json`): the Markdown and facts
464
+ of one page, keyed by hash of (page content hash, reader id + mode, the full conversion profile string);
465
+ any change of page, tool or setting is a different key, so a hit is always valid. One file per page,
466
+ written atomically after each docling run of pages and after every page of the document reader: a cancelled or
467
+ crashed run loses at most one docling run (seconds) or one reader page, a changed document re-reads only its changed pages, and two documents that contain the same
468
+ page share the work. Cached pages are branch `cached` (`was` keeps the original branch). After every
469
+ run entries that no stored trace refers to (`page.key`) and that are older than six hours are
470
+ deleted; `rag-search index cache [--clear]`, `api.page_cache`, `GET /api/conversion/page-cache` and
471
+ the System tab show the size.
472
+ * **Digital pages against their own text layer** (`layer.py`, 0.9.23). docling leaves out table cells it could not
473
+ structure, sidebars, footnotes, labels inside vector drawings and a page's running header and footer. The PDF's
474
+ text layer has all of it. For every digital page the layer (read with pypdfium2, running headers and footers --
475
+ lines repeated in the top or bottom 7 % of at least three pages -- and lone page numbers left out) is compared with
476
+ the Markdown: the share of its words and numbers that the Markdown holds (`intact` from 97 % / 98 %, `lost text`
477
+ below 90 %, in between `uncertain`; a layer of under 15 words is not judged). When text is missing, the layer lines
478
+ the Markdown does not hold are appended under `<!-- text layer: lines the conversion left out -->` (at most 20,000
479
+ characters a page; deterministic, milliseconds, no model), and the page record says so (`layer`: verdict, recalls,
480
+ `added_lines`, `before`). Not applied when the page's layer is a scanner's hidden OCR layer (invisible text) or looks garbled
481
+ (`text_ok`, `page_text_ok`): such a layer proves nothing and the page belongs to an image reader.
482
+ `RAG_SEARCH_LAYER_FILL`: `fill` (default), `report` (compare and record `would_add_lines`, change nothing, gate as
483
+ before) or `off`. Measured on the first full run (16,842 pages): 16 % had lost text, 2,212 of those had passed the gate.
484
+ Two rules since 0.9.28, from the full run of 0.9.27 where 1,021 digital pages were marked `low` by this check, 808
485
+ of them in one collection of specifications: numbers of **one digit do not count** in the number recall (they
486
+ were the bit rulers over register diagrams, list markers and footnote signs; words count from two letters on for
487
+ the same reason; a missing line of digits is still put back), and a page of which **no line is missing** and at
488
+ least 90 % of the words and numbers are there is `intact` (the rule a page already got after a fill). Evaluated
489
+ again on that run's pages: 621 of the 1,021 are no longer marked; the 400 that stay miss a number of two digits
490
+ or more or more than a tenth of their words.
491
+ * **The four lanes of 3.2** (`routed.py`, `router.py`, `residue.py`, `scanfacts.py`, `tesseract.py`; 0.9.24). Every
492
+ page that is read goes down exactly one lane, chosen once per page by the router and recorded in the page's `route`
493
+ (`runway`: the lane chosen, `reasons`, `final`: the lane whose text the page ended with, `engine`, and
494
+ `escalated_from` with the gate checks when a lane handed the page on):
495
+
496
+ | Lane | Reads | When | Gate checks that catch its failures | Default |
497
+ |---|---|---|---|---|
498
+ | **a** text layer | docling on the text layer (OCR only for embedded bitmaps) | the page has a text layer and nothing the layer does not explain | `coverage` against the PDF's own text layer (`layer.py`, below), `script`, tables, arithmetic | always on |
499
+ | **b** OCR | docling's full-page OCR for a straight PDF page; Tesseract (after straightening the page) for a skewed page (2-8 degrees) and for every image file | a scan whose page image says clean print (`router.decide_scan`) | `expected_size`, `plausibility`, `column_types`, plus the common checks | **off**: `indexer.ocr_first` = `auto` (`RAG_SEARCH_OCR_FIRST`) |
500
+ | **c** text layer + pictures | docling on the text layer, and the document reader on each large picture (profile `big_pics`) and, with the switch, on each *residue* region | a text page with large pictures; with `RAG_SEARCH_RESIDUE=auto` also one with regions of ink the text layer does not explain | `residue_read`: every picture or region asked of the reader was read | pictures **on**; regions **off**: `indexer.residue` = `auto` (`RAG_SEARCH_RESIDUE`) |
501
+ | **d** document reader | a vision model on the whole page image (guarded, then repaired, 5.1.3-5.1.4); Tesseract as the last resort for a runaway | every other scan, photo and image, a page whose text layer is garbled, and every page a cheaper lane doubts | `degenerate`, table arithmetic, repair | always on |
502
+
503
+ *Router* (`router.py`): `decide` (the page's kind: text layer, scan, image, Office, text) is as before;
504
+ `decide_digital` makes a text page lane c when it has large pictures (and, with the residue switch, regions) and a
505
+ document reader is there, otherwise lane a; `decide_scan` turns the page-image facts (`scanfacts.py`: one render at
506
+ 100 dpi, numpy + Pillow, 50-150 ms: contrast, stroke sharpness, skew, speckle, paper texture, text lines, ruled
507
+ lines, ink share) and the resolution into lane b or d through `router.THRESHOLDS` (version 2; unknown resolution, no
508
+ facts, no OCR engine mean d; a ruled table is d, because plain text loses its structure); `b_engine` picks docling or
509
+ Tesseract. With no document reader every scan is lane b by necessity (`route.forced`; docling's OCR is all there is).
510
+ An image file goes through the same router (its resolution is estimated from its pixels as if it were an A4 page, since
511
+ its dpi tag is usually 72 or 96) and is read by Tesseract in lane b.
512
+
513
+ *Escalation*: a lane-b page the gate doubts (`gate.check_page(..., ocr=True)` returns `escalate`: any failed check but
514
+ `low_resolution` and `docling_grade`, or no real text) gets one more cheap try, **Tesseract** on the straightened
515
+ page, unless the doubt is about structure (`table_shape`, `column_types`, `totals`, `running_balance`: plain text would
516
+ lose it); only if that is doubted too does the document reader read the page, and the OCR text is kept for when the
517
+ reader fails. With `indexer.escalate_digital` = `auto` (`RAG_SEARCH_ESCALATE_DIGITAL`) a text page (lane a or c) that still has lost
518
+ text or garbled text after the text-layer fill is read as an image by the document reader (d); when the reader
519
+ cannot take over, the text-layer result is kept and flagged. Both are off by default. The lane switches and the
520
+ text-layer fill are ordinary settings (`indexer.ocr_first`, `indexer.residue`, `indexer.escalate_digital`,
521
+ `indexer.layer_fill`): the Settings tab, `rag-search config set` and a Playground experiment's own configuration
522
+ set them, an environment variable of the daemon wins as for every tunable. An OCR text the reader could not replace
523
+ (it failed on the page) is kept as it is -- the page is not read by docling's OCR a second time -- and stays
524
+ flagged. A page that has a text layer and is read as an image (a garbled layer, or handed on by lane a or c) is
525
+ drawn from vectors, so the gate does not judge it by the resolution of a picture on it (`gate_profile`). The page cache keys follow the reader (`docling:scan`,
526
+ `tesseract:scan`, `<vlm>:scan`, `docling:digital+<vlm>[+res1]`), a cached document-reader result is used before a
527
+ cached OCR one, and a cached OCR result the gate now doubts goes on. The lane switches that are *on* are part of the
528
+ document's conversion profile (`|ocrfirst=auto`, `|residue=auto`, `|updigital=auto`), so switching one on
529
+ converts the documents again; the defaults add nothing.
530
+
531
+ *Residue* (`residue.py`): the regions of a text page that have ink the text layer cannot hold (a stamp, a signature, a
532
+ drawing): one render at 60 dpi, the text layer's rectangles and long straight lines (rules, table borders) taken away,
533
+ the cells of a coarse grid that still hold ink grouped; a region is at least 4 % of the page and 10 % of its width and
534
+ height and not an embedded picture the profile already has. Measured on 99 real text pages: 37 % had a region at
535
+ 2 % (too many), 10 % at 4 %.
536
+
537
+ *Gate constants measured on real pages* (`scripts/mine_traces.py`, 1,107 scanned pages that passed): characters per
538
+ unit of ink had a median of 15,105 (p05 1,664, p95 48,566), so `CHARS_PER_INK` is 15,000 and `SIZE_LOW`, `SIZE_HIGH` 0.25
539
+ and 3.0. `scripts/measure_lanes.py` reads a sample of real scans with docling's OCR and Tesseract and scores both
540
+ against the document reader's Markdown: on 38 scanned pages the router sent 3 to lane b (none wrong), the OCR gate
541
+ alone kept 29 pages of which 17 were not as good as the reader's text (partial loss, numbers missed: this is why the
542
+ router stays strict), and two cheap readers agreeing did not make a page safe (numbers differ between engines), so
543
+ OCR first stays off by default. `plausibility` judges only words in Latin letters: other scripts have other vowels.
544
+ `rag-search bench route [--ocr tesseract|docling]` runs a ladder of synthetic damaged scans (`synth.py`) through facts,
545
+ router, OCR and gate (`routeharness.py`).
546
+ * **Quality gate** (`gate.py`): every page's Markdown is checked -- `coverage` (a digital page: the layer
547
+ comparison above, or with no layer the share of characters of the text layer that survived; a scanned page with
548
+ clear ink came out empty), `script` (garbled text, OCR noise, a script
549
+ that differs from the text layer's), `docling_grade` (poor confidence), `table_shape` (ragged or
550
+ empty tables, a statement whose balance column is mostly empty because its numbers slid into a
551
+ neighbouring column, consecutive rows that repeat each other, one amount repeated in three or more
552
+ columns of most rows, amounts in the cheque / reference column, `Cr`/`Dr` balances in a debit or credit
553
+ column), `low_resolution` (an image page under 150 dpi, or a small image of unknown dpi; an image file's default dpi tag of 72 or 96 counts as unknown, so its pixels decide), `degenerate` (a
554
+ page read as an image that is a runaway of the reader: one line or phrase repeated hundreds of times, text
555
+ that is nothing but repetition, a script that is not on the page; `degenerate.py`; not applied to a text
556
+ layer), for a page read by OCR `expected_size`, `plausibility` and `column_types`, for a page whose pictures the reader
557
+ was to read `residue_read`, and the table validators (`running_balance`, `totals`) -- and ends `pass`, `low`
558
+ (a check failed: indexed, flagged) or `no_text`. For a digital page whose text is all there (layer verdict `intact`),
559
+ `table_shape`, `totals`, `running_balance` and `docling_grade` no longer make it low: they are listed under the
560
+ record's `gate.notes`, because for search the text is what counts. Failed checks and the validators' suspect cells (with
561
+ a hypothesis value each, and the number of the table) are stored in the page record's `gate`. Suspect cells are repaired by `repair.py` (5.1.4).
562
+ (A bug fixed on the way: `_page_text_ok` counted the combining vowel signs of Indic scripts as
563
+ garbage, so every Hindi/Marathi text layer looked broken to the profiler.)
564
+ * **Per-page times and events**: `time_s.read` (the run's seconds split evenly over its pages) and
565
+ `time_s.gate` per page; `step_s` (read, gate) in the document summary and the run totals; one compact
566
+ `page` event per finished page in the job's event log (`file`, `page`, `of`, `branch`, `kind`, `outcome`,
567
+ `cache`, `read_s`, `chars`, failed `gate` checks, `runway` (the lane that finished the page), `moved` (the lane that
568
+ handed it on) and `engine`), written by the pool processes themselves like
569
+ `stage` events, one `step` event before every reader call (`{file, page, what}`: "document reader", "docling, pages
570
+ 1-20", "repair", "tesseract" ...; a call can take minutes, and this is what the Workers card shows as "now" and
571
+ what the stall watch takes as a sign of life), and one `plan` event per document once its pages are profiled (or
572
+ known from its stored trace):
573
+ `{pages, branches}`, what kinds of pages it has, any format (an Office file is one page of kind `office`).
574
+ A page's **kind** (`trace.page_kind`) is what it is -- `digital`, `raster`, `image`, `office`, ... -- and its
575
+ `branch` is how this run got it: a page reused from the page cache has the branch `cached` and keeps its kind
576
+ in `was`. Everything that shows a distribution (summaries, run totals, the live view, the Collections tab)
577
+ counts by kind; the number of reused pages is a separate figure (`cached_pages`, `cached`). Pages are also counted by the
578
+ lane that finished them (`runways`, with `moves` such as `b>d`, and the lane-b `b_engines`, in the summaries, the run
579
+ totals as `runways` / `ok_runways`, and the live view with `runway_outcomes`). `runview`
580
+ aggregates the events incrementally into `live`: pages finished by kind and outcome, how many were read now
581
+ (`read`) and how many reused (`cached`), pages read per minute (reused pages are not a rate), the documents
582
+ being read with `done`/`of`, and `active`, the pages of the files being converted right now by kind (from their
583
+ `plan` events; a file not profiled yet is counted in `unprofiled`) -- and into per-lane page progress. The
584
+ Indexing tab's Convert panel shows two bars: *Pages in active files* (`live.active`) and *Pages in
585
+ successfully converted files* (`totals.ok_branches`: files that converted, not failed or empty ones).
586
+ * `docling_convert.py` returns `page_stats` per page (characters, script, tables, big pictures and
587
+ docling's own confidence scores and grade, read defensively with `getattr`); `records.build_pages`
588
+ merges them with the profile.
589
+ * **Storage**: the full trace in `markup/<coll>/<doc>.trace.json` (version 1; reused untouched when the
590
+ document is re-embedded without being re-converted); a *summary* (pages, `branches` (by kind), `outcomes`,
591
+ run-length `strip` such as `d566r2d12`, scripts, tables, `low_pages`, `poor_pages`, `time_s`, `step_s`,
592
+ `cached_pages`, `gate_failed`, `cost`,
593
+ `trace` path) in `index.meta.json["conversion"]` -- carried into collection exports
594
+ automatically -- and on the document's `doc` event. A failed or empty document keeps no trace
595
+ but its pages still count in the run totals.
596
+ * **Run totals** (`trace.RunTotals`) are live in the job's `progress.conversion` and frozen in
597
+ `summary.conversion`.
598
+ * **Run events and the run view** (`runview`, stdlib). Every phase of a run reports in the same way, through
599
+ four kinds of line in the job's event log, each carrying the writing process id (`pid`): `phase` (Convert,
600
+ Embed, Merge start / end, with their totals: files, documents, chunks, model, collections), `work` (a
601
+ process starts / ends one unit of work -- a document in Convert (`indexer.prepare_document`, framed in a
602
+ `try/finally`, so a failed document closes its work too) or Embed, a collection in Merge -- with its
603
+ outcome; the run itself closes the work of a process it stopped (`stalled`) or that ended with the pool
604
+ (`interrupted`: not counted, the document goes to a new process)), `stage` (the numbered stage reached inside that
605
+ work), `step` (the reader call about to start) and `page`. A finished document is a `doc` event with its
606
+ `collection`, file name (`source`) and `path` inside the collection; the run's document list and its counts are keyed
607
+ by collection and path (by file name alone two `statement.pdf` in two folders were one row, and the tiles counted
608
+ 1,945 of 2,059 documents). Publish is a phase of the
609
+ job record (`progress.phase = "publish"`, then `publish`, `publish_s`, `search_reload`). Workers are
610
+ generic processes, not docling's: `worker N` is a conversion-pool process, `main process` embeds and merges
611
+ (with `jobs=1` it does everything), and a lane shows the phase, document (its path inside the collection,
612
+ the one spelling used everywhere), pipeline stage, pages done, the reader call in progress (`step`: page, reader,
613
+ since when), how long the process has been quiet (`quiet_s`; the dashboard warns from ten minutes and says when the
614
+ stall watch will stop it) and its state (`working`, `idle` between documents, `done` after the run, `gone` when it
615
+ was stopped), plus its documents per phase. `runview.run_view` returns one snapshot -- `now` (the one active work of the current phase), `phases`
616
+ (status, counts, timing and per-phase figures: chunks per second for Embed, collections for Merge, generation
617
+ and reload for Publish), `lanes`, `live` (pages), `totals` -- read from a remembered file offset, so a
618
+ dashboard tick costs only the new lines. The Indexing tab's *Current run* card takes its "now:" from `now`
619
+ (not from the throttled `progress.current`, which `EventWriter` also no longer drops when the document changes),
620
+ *Phase detail* shows the picked or current phase, and *Workers* is its own card. Logs written before these
621
+ events existed still show their convert / embed lanes from `stage` events.
622
+ * **API** (`api.conversion_*`): `conversion_run` (totals, now, phases, lanes), `conversion_documents` (a run's
623
+ documents filtered by branch / outcome; `index_status` takes the same `doc_branch` / `doc_outcome`),
624
+ `conversion_trace` (summary + a line per page, or one page's full record; confined to the
625
+ workspace), `conversion_page_image` (PNG of a source page; only for sources inside a
626
+ registered location; the source is `src_path` of `index.meta.json`, and for a document
627
+ that is converted but not embedded yet -- no meta -- the file the trace names, looked for in the
628
+ collection's own folder), `conversion_markdown` (the converted Markdown of a document or of one
629
+ page, read from `markup/`; at most 2 million characters per reply), `conversion_estimate` (dry run: profiles the sources and
630
+ estimates pages per branch and docling time; converts nothing). HTTP: `GET /api/conversion/{run,
631
+ documents,trace,page-image,markdown}` (`markdown` with `raw=1` answers plain text, which the document
632
+ drawer opens in a new tab; `rag-search trace COLL/DOC --md [--page N]` prints the same), `POST /api/conversion/estimate` (allowed with `--read-only`).
633
+ `inventory.collection_info` adds a `conversion` block (totals over the stored summaries,
634
+ `poor_documents`, `low_documents`).
635
+
636
+ Everything above writes only inside `indexer_workspace/` -- sources are opened for reading only, and
637
+ a test runs indexing (with pruning and a full rebuild), export and deletion over read-only source
638
+ folders and checks every source file's bytes, mode and mtime afterwards. A change to the source, the chunk
639
+ parameters, `CHUNKER_VERSION`, `TOKENIZER_VERSION` or the model makes step 2 fail, and only that
640
+ document is redone.
641
+
642
+ #### 5.1.3 The document reader (`conversion/vlm.py`, `vlm_worker.py`, phase P3)
643
+
644
+ A vision-language model (VLM) reads page *images* and writes Markdown (tables as HTML). It is used for
645
+ scanned PDF pages (branch `raster`), image files (branch `image`, one page per frame of a multi-page TIFF;
646
+ EXIF orientation applied; HEIC with `pillow-heif`) and large pictures on text pages (branch `embedded`).
647
+
648
+ * **A child process.** `Worker` starts `python -m rag_search.core.conversion.vlm_worker --backend B --model M`
649
+ and talks JSON lines over stdin/stdout (`{"event":"ready"}`, `{"op":"read","image":...}` ->
650
+ `{"ok":true,"md":...,"tokens":N,"seconds":S,"rss_mb":R}`, `{"op":"quit"}`); library output goes to stderr.
651
+ The model's memory never sits in the indexer worker next to docling and torch, and goes back to the system
652
+ when the child exits. A reader reading thread gives every wait a timeout.
653
+ * **Backends.** `mlx` (mlx-vlm, Apple Silicon only, a dependency of the package there) or `module:attr` of your own
654
+ class (`RAG_SEARCH_VLM_BACKEND`; the tests use `tests.helpers:FakeVlmBackend`). The real backend **never
655
+ downloads**: `VlmReader.preflight` reports a model that is not in the Hugging Face cache (the child runs with
656
+ `HF_HUB_OFFLINE=1`), a missing mlx-vlm, or a machine that is not an Apple Silicon Mac.
657
+ * **Lifecycle.** `vlm.shared()` is one reader per process, created on first use (`RAG_SEARCH_VLM=off` gives
658
+ none), its process is started when the first page is queued and kept for the run (`close_shared` at the end
659
+ of `run_index` and at exit; a closed pipe also stops it). With `jobs > 1` every worker has its own reader.
660
+ * **Guards.** Free memory (`/proc/meminfo`, `vm_stat`, `RAG_SEARCH_VLM_FREE_GB`) must cover the model plus
661
+ 2 GB headroom, checked before every start (*not* sticky: memory may be back for the next page). A model
662
+ that fails to load, or a reader that has died more than twice in the run, is ruled out for the run
663
+ (`dead`). One page is limited to `RAG_SEARCH_VLM_PAGE_TIMEOUT` seconds (300): the child is killed and the
664
+ page falls back. A crash or timeout costs one page. The child's file descriptor 1 is redirected to stderr, so even a
665
+ native library that writes to it cannot corrupt the protocol. The memory check is per process: with `jobs > 1` two workers
666
+ can both pass it before either has loaded the model (the cost is swapping, not a wrong result; use `jobs=1` for
667
+ scan-heavy runs on a small machine). The reader is closed as soon as conversion is over, before the embedder loads.
668
+ * **Loop guard.** A small model decoding greedily can fall into a loop on a dense page (an annexure table,
669
+ close Devanagari print): the same line hundreds of times until the token limit, minutes per page, or a stray
670
+ script. The worker checks the text every 64 tokens (`degenerate.looping`) and stops a loop after a few
671
+ hundred tokens (`stopped: "loop"`); the page is then read again (`VlmReader.read_page_guarded`) with a
672
+ repetition penalty of 1.2 and a limit of 3072 tokens, and if that also runs away in three horizontal strips
673
+ cut at blank rows. Of the readings that are not runaways (`degenerate.assess`) the most complete (most text) is kept, so a
674
+ false alarm of the live test costs time and not text; when none is, the least repetitive one is, the page's
675
+ note says so and the gate marks the page `low`. A healthy page is read
676
+ once. A page cached by a version without the guard (`guard` of the cache entry < `vlm.GUARD_VERSION`)
677
+ whose text is a runaway is read again once instead of being served from the cache.
678
+ * **Table format.** The reader is asked for HTML tables (they can carry merged cells), but every page goes
679
+ through `tables.normalize_html_tables` in the router's finish step: a table without merged cells, nesting
680
+ or unclosed markup becomes a Markdown pipe table, cell for cell (a `|` is escaped); the rest stays HTML. The
681
+ chunker keeps an HTML table whole across blank lines and, when it is larger than a chunk, cuts it by `<tr>`
682
+ rows into valid `<table>` pieces that each carry the header rows (`CHUNKER_VERSION` v2).
683
+ `tables.join_split_pipe_tables` (same step) joins the data rows of a pipe table that a reader cut from its
684
+ header with a blank line (rows with the header's number of cells, or up to two fewer): the parser, the chunker
685
+ and the running-balance check otherwise see a table of three header rows and nothing to verify. In a real
686
+ passbook page this was 28 rows whose arithmetic had never been checked. `table_shape` accepts a row that
687
+ leaves out the header's last (empty) column; any other difference in the number of cells is a shifted row.
688
+ The conversion profile of a document carries `post=<POST_VERSION>` (not the page cache's key), so a change
689
+ to this kind of post-processing re-converts documents from the page cache and reads no page again.
690
+ (`POST_VERSION` is 4 since 0.9.26: a page the repair model read again kept its HTML tables until then, about 92
691
+ pages of the first full run; the bump converts every document again from the page cache and embeds it again once.)
692
+ * **Fallback, always per page.** Whatever the VLM cannot read goes to docling with full-page OCR: branch
693
+ `fallback`, and the page's `note` says why (`crashed: ...`, `timeout: ...`, `unavailable: only 1.5 GB of
694
+ memory is free ...`). Image files have no page fallback: the reader raises and the indexer converts the file
695
+ whole with docling (`note`: "document reader not used (...)"). A photograph without text comes back empty
696
+ and is "no text".
697
+ * **Last resort: Tesseract (`tesseract.py`).** When a page is still a runaway after the reader's retries and the
698
+ repair model, or docling's OCR and Apple Vision found no text, the page image (3000 px) is read by the
699
+ `tesseract` binary with `mar+hin+eng` (the installed subset; `RAG_SEARCH_TESSERACT_LANG`, `RAG_SEARCH_TESSERACT=off`),
700
+ psm 4: plain text in reading order, no tables, 1 to 13 s a page, and it cannot loop. Kept only when it is real
701
+ text and not a runaway; the page record says `tesseract` (branch `fallback`) and the note why. A missing
702
+ binary or language is a note, not a failure (`rag-search doctor` checks both; `rag-search setup` installs them).
703
+ * **Last resort: Apple Vision.** docling's OCR cannot read a photographed page (its layout model calls the
704
+ whole photo one picture and drops the text inside it). When a `fallback` page comes back without real text
705
+ (only `<!-- image -->` and a class label), or a document converted whole has no text at all, and this Mac
706
+ has `ocrmac`, the page image is read by Apple's text recognition (`conversion/applevision.py`) and the
707
+ result is plain text in reading order (no tables). The page record says `apple-vision` as its reader; where
708
+ Apple Vision cannot run (not a Mac, no `ocrmac`) the note says so. A document that is converted whole
709
+ always says why it was not read page by page (`note`: "not read page by page: RAG_SEARCH_ROUTING=...").
710
+ * **Pictures on text pages.** The profile keeps the boxes of pictures covering 25-85 % of a text page
711
+ (`big_pics`, at most 4). docling reads the page, the VLM reads each crop, and only the text the page does not
712
+ already hold is appended (`_new_text`: whole text already present -> nothing; tables are kept whole). The
713
+ page is branch `embedded`. If a picture cannot be read the page stays a plain `digital` page and is not
714
+ cached as complete. (Pictures inside Office files stay docling's.)
715
+ * **Page cache.** The key's reader id is `vlm:<model>#<prompt version>`, so another model, another prompt
716
+ (`PROMPT_VERSION`) or switching the reader off is a different key; a fallback result is cached under the
717
+ docling key, so a run without a usable reader still reads nothing twice, and a later run with a working one
718
+ reads those pages again. Cache entries remember the branch, reader info, tokens and model.
719
+ * **Records.** A VLM page's record has `reader {"tool":"vlm","model","mode":"page|picture"}`, `tokens`,
720
+ `gpu_s` (seconds in the model) and `time_s.read` (render + model); summaries and run totals add `tokens`,
721
+ `gpu_s`, `models`; `page` events carry tokens/gpu_s/model, the live view `tokens` and `tokens_per_s`.
722
+ * **Models.** `models.VLM_CATALOG` (kinds `reader` and `repair`, not part of `KINDS`: choosing one never
723
+ re-embeds): `mlx-community/Qwen3-VL-4B-Instruct-4bit` (default, 3.1 GB), `mlx-community/PaddleOCR-VL-1.5-8bit`
724
+ (1.1 GB; plain text from whole pages) and `-bf16`. Selection in `config.json` `models.reader` / `models.repair`
725
+ or `RAG_SEARCH_VLM_MODEL` / `RAG_SEARCH_REPAIR_MODEL`; any other ORG/NAME is allowed (prompted as an instruct
726
+ model). `rag-search models reader|repair [ID]`, `models download --reader`, `POST /api/models/reader`, the
727
+ Models tab's *Document reader* card (the download uses the ordinary model task). Prompts per style are in
728
+ `vlm.py` (`instruct` asks for Markdown with HTML tables and exact numbers; `paddleocr` uses `OCR:`).
729
+ * **Settings.** `indexer.vlm` (`auto` | `off`, `RAG_SEARCH_VLM`, `--doc-reader`).
730
+ * **Benchmark.** Engines `vlm` (the reader alone; a page it cannot read fails the file, so a measurement never
731
+ silently measures docling) and `routed` (the production path without the page cache) next to `current`.
732
+ * **Not verified in CI:** everything above runs against a fake backend (the child process and protocol are
733
+ real); the MLX backend, real model output, memory use and speed are measured only on an Apple Silicon Mac.
734
+
735
+ #### 5.1.4 Repair and tables across pages (`conversion/repair.py`, `reconcile.py`, phase P4)
736
+
737
+ **Repair** fixes one kind of damage cheaply and safely: a digit the document reader got wrong in a table whose
738
+ arithmetic is checkable (a bank statement's running balance, a Total row). It runs inside `_Converter.finish`,
739
+ only for pages read as images (`raster`, `image`, `fallback`; a born-digital text layer is exact, so an
740
+ arithmetic failure there is the document's own) and only when the gate reports suspect cells:
741
+
742
+ 1. The validators (`validators.py`) name the suspect cell (table, row, column, role, what is written, and a
743
+ *hypothesis*: what the cell would have to be for the sums to hold).
744
+ 2. An **independent second reader** (`repair.second_reader()`; Apple Vision through `ocrmac` on the Mac,
745
+ `RAG_SEARCH_REPAIR_SECOND` = `auto` | `off` | `module:attr`) reads the page image and returns words with boxes.
746
+ `repair.locate` finds the row by matching the row's *other* cells to the words of one text line (the line that
747
+ matches most; a tie, as with repeated rows, is refused), and the cell as the words between the row's
748
+ neighbouring cells (several candidates are told apart by similarity to what was written). Its box is the crop.
749
+ 3. The **repair model** (`vlm.repair_shared()`: the document reader's own process when `models.repair` is the same
750
+ model, else a second reader) reads the crop alone (`PROMPT_CELL`: only the number, as printed).
751
+ 4. The new text is **accepted only if** it is a number, it is not the text already there, it equals what the
752
+ second reader saw (`norm_number_text`), *and* `validators.recheck_with` finds the cell no longer suspect with no
753
+ new suspect. Anything else is logged and the page keeps the reader's text: two models agreeing on a digit that
754
+ also makes the arithmetic hold is as good as certain; less than that is left to a person (the page stays `low`).
755
+ 5. The cell is replaced in place (`tables.replace_cell`: pipe tables and HTML tables without merged cells; only
756
+ that cell's text changes). Suspects are recomputed after every accepted fix (one fix can explain the next row),
757
+ at most 10 cells per page.
758
+ 6. **Escalation (0.9.21).** The repair model is by default **Qwen3-VL 8B 4-bit**, the reader the 4B. Any scanned page
759
+ the gate flags -- not only one with a suspect cell: shifted columns, a runaway, a failed balance, an empty
760
+ page -- goes to the repair model as a whole (the cell step is skipped when there is no suspect cell), through
761
+ `read_page_guarded`, so a loop of the 8B is stopped and retried like the 4B's. The re-read replaces the page when it
762
+ passes the gate and keeps the tables, 90 % of the *different* numbers and 80 % of the *different* words of the
763
+ first reading (different, because a page with mixed-up columns repeats its figures and the correct reading is
764
+ shorter); a runaway is replaced by any clean reading. `ALGO_VERSION` r2: pages whose repair was tried before are tried
765
+ again. With the same model for both (`models.repair` set to the reader's) there is no escalation. In
766
+ the original design: If cells remain suspect and the repair model is *not* the reader model, the whole page is read once more by the
767
+ repair model and kept only if it passes the gate (ignoring `low_resolution`) **and** is not smaller than the first
768
+ reading (`repair.not_smaller`: at least as many tables, 90 % of the numbers, 80 % of the text; a page that lost its
769
+ table would otherwise pass the gate trivially). Any error in repair, an unreadable source included, is a note on the
770
+ page, never a failure of the document.
771
+
772
+ The page is then gated again: `repaired` when it now passes and something was fixed, otherwise `low`. A page repaired earlier
773
+ stays repaired from the page cache even if repair is switched off later (the cache key is the reader's, not the repair
774
+ setting's); a second attempt on a cached page adds its cells to the first one's record. A scan under
775
+ 150 dpi stays `low` even when repaired (the flag is about the page, the fix about a cell). The record gets
776
+ `repair {model, second, tier: cells|page, tried, fixed, cells:[{table,row,col,role,before,after,status,why,second,
777
+ read,expected}]}` (`status`: `fixed`, `unchanged`, `disagree`, `not_a_number`, `not_confirmed`, `not_located`,
778
+ `not_editable`, `error`), `time_s.repair`, and the tokens / GPU seconds of the cell reads are added to the page's.
779
+ The repaired Markdown replaces the cached page (the cache entry carries `repair.tag` = method version, repair
780
+ model, second reader), so a page is not tried again with the same models, a failed attempt included; another
781
+ repair model or second reader tries again. An attempt that could not run at all -- no memory free just then, the
782
+ reader crashed (`complete: false`) -- is not remembered as an attempt: the tag is left empty and the next run
783
+ tries again (a page that timed out is remembered: it would time out again). Without a second reader (not a Mac, ocrmac missing, `off`) cells are
784
+ not repaired and the page's note says why; sources are never touched. `indexer.repair` (`auto` | `off`,
785
+ `RAG_SEARCH_REPAIR`, `--repair`) switches the step off.
786
+
787
+ **Tables across pages** (`reconcile.merge`, run by the converter after every page of the document is read, on cached
788
+ pages too): when the last thing on page N is a table and the first thing on page N+1 is a table of the same
789
+ width whose first row is the same header again or plainly data (an amount with decimals or digit grouping, or a date; not a row of years, of 1 2 3 column numbers or
790
+ column titles), they are one table. A headerless continuation gets the header (so its chunks say what the columns
791
+ are, and its own checks work); the *merged* table runs through the validators; suspect cells that only the merged
792
+ table shows (the first row of page N+1 against the last balance of page N, a Total that spans the break) are
793
+ recorded on the page they sit on (`reconcile {role: starts|continues, with, header: added|repeated, rows, ok,
794
+ violations}`, gate check `table_across_pages`) and the page becomes `low`. They are flagged, not repaired (a suspect is identified by its text *and its row*, so a boundary suspect is not hidden
795
+ by a local one with the same figure; the `page` event of the live view is sent before this step, so the live counters can
796
+ show `pass` for a page the trace and the summary then call `low`). Pages stay
797
+ separate (page markers untouched), so a chunk still has one page number and the chunker is unchanged.
798
+
799
+ **Flagged pages reach search.** A chunk of a `low` page gets `metadata.confidence = "low"` (absent otherwise,
800
+ written at index time from the page records), search results carry `"confidence": "low"`, the CLI prints
801
+ "(low-confidence page: check the source)", the MCP `rag_search` description tells the model to caveat such a hit,
802
+ and the Search playground shows a chip. The collection view's conversion block lists the documents and pages that
803
+ need attention. Documents indexed before this version get the flag when they are next indexed.
804
+
805
+ * **Not verified in CI:** the cell locator and acceptance rule are tested against fake readers with synthetic
806
+ boxes; Apple Vision's real output (phrase granularity, the bottom-left origin of its boxes), the crop reads of a
807
+ real model, and the repair rate on real scans are measured only on a Mac with the gold set (P1).
808
+
809
+ #### 5.1.2 Measuring conversion (`core/conversion/bench.py`, phase P1)
810
+
811
+ Before any reader is changed, how well a page is read is *measured* on pages with known text:
812
+
813
+ * A **gold set** is `<home>/conversion_gold/<set>/gold.json` plus `images/<id>.png`: pages of your own
814
+ documents, each with a failure `class` (`digital-table`, `scan-table`, `devanagari-text`, `image`, ...,
815
+ from `bench.classify` on the trace records), the checked `truth` Markdown, optional `truth_tables`
816
+ and search `queries`, and the source's SHA-256 (a changed source is skipped, not mis-scored).
817
+ `rag-search bench gold init SET` samples pages per class from the stored traces (one page per
818
+ document first), renders them and **pre-fills `truth` with what the pipeline read**; such entries are
819
+ `"verified": false` and are not measured until a person has corrected the text against the image and
820
+ set `"verified": true` (`--drafts` overrides: comparing the pipeline with itself).
821
+ * A **run** (`rag-search bench run SET [--engine E] [--name N]`) reads the verified pages with an
822
+ *engine* (`engines.py`: `read_pages(src, pages) -> {pages: {n: markdown}, seconds, pages_read}`;
823
+ `current` = whole-document docling with today's settings; `module:attr` for another) and stores
824
+ `<home>/conversion_bench/<set>/<run-id>.json`: per page and per class **numeric cells exact** (same row
825
+ and column; rows are matched by their text cells, so one lost row does not shift the rest) and *found
826
+ anywhere*, **CER** (bit-parallel Levenshtein on `tables.plain_text`), **table similarity** (cell
827
+ sequence edit distance -- not the published TEDS), **balance checks** (the P4 validators on the
828
+ predicted tables), **search phrases found**, s/page and the process's CPU time and peak memory.
829
+ `bench compare SET A B` shows the change of every measure per class and the pages that got worse.
830
+ * Sources are resolved through registered locations only and opened read-only; runs
831
+ write nothing outside `conversion_bench/`, never the index, markup or serving folders. The dashboard's
832
+ Playground tab lists sets and runs and compares two (`GET /api/conversion/bench`, `bench-run`,
833
+ `bench-compare`); creating and running are CLI-only because they load readers and take minutes.
834
+ * `tables.py` (pipe and HTML tables with row/column spans, Indian and western number grouping,
835
+ `Dr`/`Cr`, parentheses) and `validators.py` (running balance in either row order -- one damaged amount
836
+ fails one row, a wrong balance fails two consecutive rows and names the balance cell -- and totals; each
837
+ violation carries a *hypothesis* value; titles printed on two or three lines, as in a Marathi/Hindi/English passbook, are stacked into one header before the columns are recognised) are stdlib-only and shared by the metrics, the gate and repair.
838
+
839
+ ### 5.2 Search flow (search daemon, `core/search.py`)
840
+
841
+ ```
842
+ request: query, top_k (1..25, default 5), collections (optional), client identity,
843
+ stages (default bm25+dense+rerank), retrieval_pool/rerank_pool/rrf_k (optional
844
+ troubleshooting overrides, clamped server-side -- see below)
845
+ │
846
+ ▼ access rules: no collections named -> every collection this client may use;
847
+ │ a named one must exist AND be allowed, else `bad_request: unknown collection`
848
+ │ (a restricted collection is indistinguishable from a missing one)
849
+ ▼ models still loading? -> wait up to wait_s, then `warming_up`
850
+ │
851
+ │ for every collection in scope (indexes are already in memory: nodes, vectors, BM25)
852
+ ├───────────────────────────────────────────┬──────────────────────────────────────────┐
853
+ ▼ ▼ │
854
+ KEYWORD BRANCH (skipped if "bm25" not DENSE BRANCH (skipped if "dense" not │
855
+ in `stages`) in `stages` -- the embed call itself │
856
+ tokenize(query) is skipped, not just the dot product) │
857
+ BM25 score of every chunk bge-m3 encode(query) ─▶ q (unit vector) │
858
+ keep the best pool_n chunks with score > 0 cosine = embeddings @ q │
859
+ keep the best pool_n chunks │
860
+ │ ranked list A │ ranked list B │
861
+ └─────────────────────┬─────────────────────┘ pool_n = max(4 x top_k, 20) │
862
+ ▼ by default, or `retrieval_pool` │
863
+ both stages active? ──yes──▶ RECIPROCAL RANK FUSION (RRF) │
864
+ │no score(chunk) = 1/(k + rank in A) + 1/(k + rank in B) │
865
+ ▼ (k = 60 by default, or `rrf_k`; rank starts at 1; a chunk │
866
+ rank by that one stage's missing from a list adds nothing for that list; chunks are │
867
+ raw score instead -- RRF keyed by (collection, id), so all collections share one │
868
+ over one list is just its pool) │
869
+ rank order, discarding ▼ │
870
+ the score magnitude keep the top max(3 x top_k, 15) chunks, at most 60 by │
871
+ default, or `rerank_pool` (clamped to <= 100) │
872
+ ▼ │
873
+ CROSS-ENCODER RERANK BAAI/bge-reranker-v2-m3 (skipped if "rerank" not in │
874
+ reads (query, chunk) TOGETHER for each pair -> relevance in [0, 1] (sigmoid) │
875
+ `stages`, or if the reranker is disabled -- disabled is reported as `rerank_error`,│
876
+ it is never lazily loaded mid-search just because a request asked for it) │
877
+ ▼ │
878
+ sort by rerank score, keep top_k │
879
+ (reranker off or failing -> keep the prior order and report `rerank_error`) │
880
+ ▼
881
+ result per hit: rank, score (the order key: rerank_score, else rrf_score, else the single
882
+ active stage's raw score), rrf_score, bm25_score/bm25_rank, dense_score/dense_rank,
883
+ rerank_score (each null when that stage didn't produce the hit or wasn't run), collection,
884
+ file, source, page, heading, text (up to 1200 characters), and `confidence: "low"` when the chunk's
885
+ page was flagged by the quality gate (5.1.4)
886
+ timing: retrieve/rerank/total (as before) plus the effective stages/retrieval_pool/rerank_pool/
887
+ rrf_k used, bm25_candidates/dense_candidates, overlap_count/bm25_only_count/dense_only_count,
888
+ and a top1/gap per stage (bm25_gap/dense_gap/rerank_gap) -- a large gap between the best and
889
+ second-best candidate in one stage is a sign that stage found a clearly-best passage that
890
+ fusion or reranking might bury
891
+ ```
892
+
893
+ Why two stages: BM25 finds exact terms (identifiers, command names, version numbers) that
894
+ embeddings blur; embeddings find paraphrases that share no words with the question. RRF merges the
895
+ two rankings without having to calibrate their very different score scales (BM25 is unbounded,
896
+ cosine similarity is `[0, 1]`). The cross-encoder is too slow to run on every chunk, so it only
897
+ re-orders the few dozen candidates that survived fusion. `rag_grep` / `rag-search grep` is a
898
+ separate path: a regular-expression scan of the converted Markdown (in an isolated child process
899
+ with a timeout), for exact text.
900
+
901
+ **Troubleshooting/tuning knobs** (`rag-search search --stages/--retrieval-pool/--rerank-pool/--rrf-k
902
+ --explain`, and the dashboard's Search tab): every one of the overrides above is optional and
903
+ additive -- a request that sets none of them gets exactly the response shape and pool sizes this
904
+ diagram already describes by default, so nothing that reads today's `search()` reply breaks. The
905
+ overrides exist to isolate one retriever (does BM25 alone find it? does dense alone?), or to see
906
+ whether a different RRF constant or pool size changes the outcome, without needing a code change
907
+ or a daemon restart. They are validated and clamped at the request boundary (`search_daemon.py`
908
+ for the socket protocol; `spec.clamp_retrieval_pool`/`clamp_rerank_pool`/`clamp_rrf_k` for the
909
+ ceilings) before ever reaching the engine, so a bad or oversized value from the CLI or the
910
+ dashboard can never touch the model. The MCP `rag_search` tool does not expose these overrides --
911
+ they are a human-debugging surface, not extra knobs for an LLM caller.
912
+
913
+ Backends are pluggable through `RAG_SEARCH_EMBEDDER` / `RAG_SEARCH_RERANKER` (`module:Class`); the
914
+ tests use a hashed bag-of-words embedder so the whole stack, including real daemon and worker
915
+ processes, runs without downloading models.
916
+
917
+ ### 5.3 Models (`models.py`, `model_tasks.py`)
918
+
919
+ * `models.py` (stdlib only) holds the catalogue (`ModelSpec`: size, memory, licence, backend, query prefix,
920
+ library requirements; the document reader models are a separate catalogue, 5.1.3), the selection (`selection()` = `$RAG_SEARCH_MODEL` / `$RAG_SEARCH_RERANK_MODEL`, else
921
+ `config.json` `models.*`, else the default; `paths.model_name()` / `rerank_model_name()` use it, so every process
922
+ agrees and reads the file each time), the fit estimate (weights, index, 1.5 GB overhead against 60% of RAM or
923
+ `models.memory_limit_gb`), what is in the Hugging Face cache (`cache_state`), and what a switch costs
924
+ (`reindex_estimate` from the per-document `index.meta.json` files).
925
+ * `model_tasks.py` runs one task at a time (`run/models.task.json` + a lock held by the runner; a record that
926
+ says "running" without the lock is reported as failed): **download** (`snapshot_download` in a thread,
927
+ progress = bytes in the cache blobs / the size the hub reports), **verify** (a subprocess loads the model and
928
+ checks three test questions each rank their own passage first), **switch** (plan → download → verify → write
929
+ `config.json` → apply). Applying a reranker calls `reload` on the search daemon; applying an embedding model
930
+ starts `index new` (every document is stale because `is_fresh` includes the model; the converted Markdown is
931
+ reused through its `.sha256` sidecar). The CLI runs a task in the foreground; the dashboard starts it detached
932
+ (`GET /api/models` polls the record).
933
+ * `core/embedding.py` has three backends chosen from the catalogue: `Embedder` (sentence-transformers, with the
934
+ optional query instruction for Qwen3-Embedding), `Reranker` (cross-encoder) and `Qwen3Reranker` (a causal LM
935
+ scored as P("yes")). Outputs are checked for NaN/inf, and `RAG_SEARCH_DTYPE` forces the precision. Both
936
+ rerankers honour `RAG_SEARCH_RERANK_BATCH` / `RAG_SEARCH_RERANK_MAX_LEN` (`spec.py`: `rerank_batch`,
937
+ `rerank_max_len`).
938
+ * Loading is **offline** when it can be (`embedding._load_model`, `embedding.offline`): a model whose complete
939
+ copy is in the Hugging Face cache is loaded with `local_files_only=True` *and* with the libraries in offline mode
940
+ (`HF_HUB_OFFLINE`, `TRANSFORMERS_OFFLINE`, set for the load in the environment and in the already-imported
941
+ settings, and put back afterwards). `local_files_only` alone was not enough: transformers starts a background
942
+ check for a converted copy of the weights on the Hub, four requests naming the model at every daemon start and
943
+ every embed phase (seen in the logs until 0.9.24). With both, no request leaves the machine, and one with blocked
944
+ or no internet starts as quickly as any other; `HF_HUB_DISABLE_TELEMETRY=1` is set as well; if that fails (an older library, an incomplete cache) the normal
945
+ load runs, and a failed download of a model that is not cached is explained with
946
+ `model_tasks.explain_download_error`. On PyTorch < 2.6 the transformers `torch.load` safety check is relaxed
947
+ for trusted `BAAI/*` checkpoints **only while that one model loads** (`trusted_load`) and restored afterwards
948
+ (in every transformers module), so a later, untrusted model in the same long-lived process is checked again;
949
+ model loads in one process are serialised so two overlapping loads never see each other's relaxation.
950
+ * `models.cached_revision(id)` reads the commit `refs/main` points at in the cache; the embedder records it and
951
+ the indexer writes it to `index.meta.json` as `model_revision` (a model id names a repository, not fixed weights).
952
+ * Publishing cannot mix embedding models (`publish._collect`), so a half-finished re-embedding never goes live.
953
+
954
+ ### 5.4 Playground (`core/playground.py`)
955
+
956
+ A playground experiment is a sandbox for trying a different embedding model, reranker, chunk size
957
+ or pool/RRF tunable against a small sample of documents, and for benchmarking the result, without
958
+ any of it touching production.
959
+
960
+ ```
961
+ <home>/playground/<name>/
962
+ locations.json its own source folders (registered like production's with `playground source`
963
+ or `create --from`; read in place, never copied)
964
+ workspace/index/<coll>/_all/ nodes.json, embeddings.npy, merge.manifest.json (own indexer_workspace)
965
+ config.json embedding_model, rerank_model, reader_model, repair_model,
966
+ chunk_size, chunk_overlap, rerank, stages, retrieval_pool,
967
+ rerank_pool, rrf_k, and every conversion tunable (ocr, table_mode, ...)
968
+ jobs/<run-id>.json one index / bench run: status, progress, summary (its own, never production's)
969
+ jobs/<run-id>.events.jsonl the run's event log: stage, page, progress, doc and query events
970
+ jobs/<run-id>.{log,pid} the child process's output and pid
971
+ bench/queries.jsonl labeled queries (user-authored; a .example template is created)
972
+ bench/runs/<run-id>.json one recorded benchmark run
973
+ ```
974
+
975
+ **Structural isolation.** `paths.get_playground_paths(base, name)` builds a full, independent
976
+ `Paths` (own `home`, `docs`, `workspace`/`index`, `serving`, `run`, `jobs`) rooted at
977
+ `playground/<name>/` inside the real `$RAG_SEARCH_HOME`. `serving/` and `run/` exist on
978
+ that dataclass for structural completeness but nothing in the playground code path writes to them
979
+ (`jobs/` is the experiment's own, see "Runs" below):
980
+ there is no generation/publish machinery (no `serving/gen-N`, no `serving/current` symlink swap,
981
+ no `catalog.json`) and no daemon. Every `rag-search playground ...` command loads the small index
982
+ and the experiment's own models in that one process and exits — the same execution shape as
983
+ `rag-search index foreground` — so a playground run can never be mistaken for, or interfere with, a
984
+ production generation. `test_production_untouched_by_playground` snapshots production's `serving/`
985
+ tree, `serving/current` target, `config.json` and `run/` listing before and after a full
986
+ create→index→search→bench→remove cycle and asserts byte-for-byte equality.
987
+
988
+ **Reuse, not reimplementation.** `build_index()` calls `core.indexer.run_index()` directly — the
989
+ exact same function production indexing uses — pointed at the experiment's own `Paths` and
990
+ documents. `search()` and `bench()` construct a `core.search.SearchEngine` and populate
991
+ `engine.gen = Generation(indexes={...})` straight from the experiment's
992
+ `workspace/index/<coll>/_all/` directories, bypassing `prepare_generation()`/`catalog.json`/
993
+ `serving/current` entirely, then call the engine's real `search()` — the same BM25/dense/RRF/
994
+ rerank/stage-toggle logic described in §5.2, with the same `--stages`/`--retrieval-pool`/
995
+ `--rerank-pool`/`--rrf-k`/`--explain` overrides, runs unmodified. Nothing about ranking or fusion is
996
+ duplicated for the playground, so there is no drift risk between what an experiment shows and what
997
+ production would do with the same models and tunables.
998
+
999
+ **Model resolution.** `paths.configured_model()` (and therefore the bare `model_name()`/
1000
+ `rerank_model_name()`) always reads `default_home()/config.json` — production's config — regardless
1001
+ of what `Paths.home` a caller actually built with. Naively calling `run_index()` or constructing
1002
+ `SearchEngine()` for a playground experiment would therefore silently pick up production's model
1003
+ instead of the experiment's own. This is avoided two ways: `run_index()` takes an additive
1004
+ `model: str | None = None` override (defaults to `model_name()` when omitted, so production
1005
+ behaviour is unchanged) which playground code always sets explicitly from the experiment's
1006
+ `config.json`; and `SearchEngine` is always constructed with pre-built `embedder=`/`reranker=`
1007
+ objects (never `None`), which makes it skip its internal `model_name()` calls altogether. Because
1008
+ `SearchEngine.search()`'s lazy-load guard (`if self.embedder is None: self.load_models()`) is
1009
+ skipped once the embedder is already set, playground code calls `engine.load_models()` explicitly
1010
+ right after construction.
1011
+
1012
+ **Same pipeline, same numbers.** An experiment's index build is `run_index()` with the experiment's own
1013
+ `Paths`, so it goes through exactly the stages of 5.0 (2 Fingerprint, 3 Convert with 3.1-3.5, 4 Chunk,
1014
+ 5 Embed, 6 Write, 7 Merge); 8 Publish does not exist in a sandbox. The experiment's settings are grouped by
1015
+ those stages in the Playground tab: the document reader model (3.2) and the repair model (3.4) -- both
1016
+ pinnable per experiment through `reader_model` / `repair_model`, blank meaning production's choice from the
1017
+ Models tab --, the conversion tunables (3.2 / 3.4, the same `spec.py` registry the Settings tab uses), chunk
1018
+ size and overlap (4), the embedding model (5), and the search defaults (S2-S6). `experiment_env()` builds the
1019
+ environment a run applies with the same `effective.settings_env` the production indexer daemon uses for its
1020
+ workers: an environment variable the dashboard was started with still wins, a blank / 0 value adds nothing,
1021
+ the pinned reader/repair model becomes `RAG_SEARCH_VLM_MODEL` / `RAG_SEARCH_REPAIR_MODEL` (which is what
1022
+ `models.reader_choice()` reads), and the *hardware* settings of the Models section (device, precision, batch
1023
+ sizes, memory limit) follow production because they describe this computer. The environment is applied for
1024
+ the one call (`_experiment_env`) and removed afterwards. `effective_settings(base, name)` /
1025
+ `rag-search playground settings NAME` / `POST /api/playground/settings` report what the next run really
1026
+ uses, per stage, with where each value comes from (this experiment, production config, environment, default)
1027
+ -- the same `effective.resolve` as production, so the Playground shows the truth, not a copy of the form.
1028
+ Promoting carries a pinned reader / repair model (`models.set_vlm_selection`) along with the models, chunk and
1029
+ search settings; conversion tunables are not promoted (they are production's Settings tab).
1030
+
1031
+ **Runs and live progress (`playground_runs.py`, stdlib only).** An index build or a benchmark takes minutes, so
1032
+ the dashboard starts it detached (`POST /api/playground/index|bench` answers at once with the run id) and
1033
+ watches it the way production watches an indexing run -- with the same machinery. The child
1034
+ (`rag-search playground index|bench NAME --job ID`) keeps a job record `jobs/<id>.json` (status queued ->
1035
+ running -> done / failed / cancelled, progress, summary, its pid) and an event log `jobs/<id>.events.jsonl`:
1036
+ `stage` events with the stage number, one `page` event per converted page (branch, outcome, cache, read time,
1037
+ characters, tokens, model, failed gate checks), `progress` and `doc` events from `run_index`, and for a
1038
+ bench one `query` event per query with the time each search stage took. `jobs.documents` and
1039
+ `conversion.runview` read these exactly as they do for production, `playground_runs.view()` adds a per
1040
+ document timeline (its stage events and pages), and `POST /api/playground/run` returns it all: the Playground
1041
+ tab polls it once a second and shows the numbered stage strip (1-7, 3.1-3.5) with counts, the pages read
1042
+ right now, the workers, every document with its stage chips and a page grid (a page table with branch,
1043
+ outcome, time, model and gate checks opens under it), and for a bench every query. A run whose process died
1044
+ is marked failed with the tail of its log (`_settle`), one run per experiment at a time is enforced,
1045
+ `POST /api/playground/cancel` stops it, `POST /api/playground/runs` lists the history and `rag-search
1046
+ playground status NAME` prints the same view in a terminal. Search is short and still answers at once (in a
1047
+ child process, with the S2-S5 timing of that search). The dashboard never loads a model: runs and searches
1048
+ are `rag-search playground ...` child processes (`ui/server.py`'s `_playground_cli` / `playground_runs.start`),
1049
+ so a long-lived server can never race a per-experiment environment against another request.
1050
+
1051
+ **Benchmarking.** A label in `bench/queries.jsonl` is pinned to *document + page*
1052
+ (`{"query": ..., "relevant": [{"file": "a.pdf", "page": 4}, ...]}`), not an exact chunk id, because
1053
+ chunk boundaries shift whenever chunk params or the embedding model change — a label should still
1054
+ mean the same thing after a model switch. `bench()` replays every labeled query through `search()`
1055
+ and computes, per query and averaged:
1056
+
1057
+ * **Recall@k** — 1 if any of the top-k hits matches a labeled `(file, page)`, else 0.
1058
+ * **MRR** — `1 / rank` of the first matching hit (0 if none in the top-k).
1059
+ * **nDCG@k** — binary relevance, `dcg = Σ rel_i / log2(i + 2)` over the top-k, divided by the ideal
1060
+ DCG over `min(n_relevant_labels, k)` ideal positions.
1061
+ * **latency** — mean, p50, p95 over the replayed queries.
1062
+
1063
+ Each run is tagged with the exact combo that produced it (embedding model, rerank model, chunk
1064
+ size/overlap, stages, retrieval/rerank pool sizes, RRF k, k) and written to
1065
+ `bench/runs/<run-id>.json`; `compare()` lists every recorded run for an experiment side by side so a
1066
+ change in a tunable can be judged by its effect on the metrics rather than by eyeballing one search
1067
+ at a time.
1068
+
1069
+ **The production bridge.** `production_snapshot(base)` reads production's *effective* settings
1070
+ (`config.load_config(base)`, plus the same env-var-wins precedence `paths.model_name()` /
1071
+ `rerank_model_name()` use) into the same shape as an experiment's `config.json`. Two entry points
1072
+ build on it, and neither ever touches an index:
1073
+
1074
+ * `create_experiment(base, name, from_production=True)` seeds a new experiment's `config.json`
1075
+ from the snapshot instead of this module's own `DEFAULT_CONFIG`, so a benchmark starts from what
1076
+ real searches actually get (`rag-search playground create NAME --from-production`, or the
1077
+ Playground tab's "copy from production" checkbox).
1078
+ * `promotion_preview(base, name)` diffs an experiment's config against the snapshot (only the
1079
+ fields that actually differ appear in `changes`), and calls `models.reindex_estimate()` -- the
1080
+ same estimate `rag-search models set` shows before an embedding-model switch -- whenever the
1081
+ diff touches `embedding_model`, `chunk_size` or `chunk_overlap`: those three change what a
1082
+ stored chunk/vector *means*, so the existing production index would be stale until it is
1083
+ rebuilt; a different reranker or search tunable takes effect on the next search/run with nothing
1084
+ to rebuild. `promote_to_production(base, name, confirm=False)` calls the preview first and
1085
+ raises `PlaygroundError` (never writing anything) when it needs a reindex and `confirm` was not
1086
+ passed; once confirmed (or when nothing needs a reindex), it writes only the fields that changed
1087
+ -- `models.set_selection()` for the embedding model/reranker (so a custom, non-catalogue id is
1088
+ validated the same way `rag-search models set` validates one), `spec.validate_section()` +
1089
+ `config.update_config()` for chunk size/overlap and the search tunables. `rag-search playground
1090
+ promote NAME --dry-run` / `--confirm` and the Playground tab's "Promote to production" button
1091
+ (which shows the same diff and reindex cost in a confirmation dialog) are the two front-ends.
1092
+
1093
+ ### 5.5 Source locations, collection export/import and deletion
1094
+
1095
+ **Locations** (`locations.py`, stdlib). `locations.json` maps a collection name to a folder;
1096
+ it is the only source of documents (no registered location = nothing to index, `locations.NO_LOCATIONS`).
1097
+ `paths.SourceRoots` (the locations, plain data so it travels to the conversion processes) is what
1098
+ `mirror_rel`, `index_dir_for` and `markup_path_for` take, so a document gets the workspace path
1099
+ `<name>/<path inside the folder>`; a file in no location raises `ValueError`. `add` refuses taken names
1100
+ (a location, an import) and folders that overlap the data folder or another location. A
1101
+ collection is a registered location or an import and nothing else; an index folder that is neither is not
1102
+ listed (`catalog.known_names`) and a full indexing run removes its derived data (`ScanPlan.orphans`,
1103
+ `summary.orphans_removed`). A playground experiment has its own `locations.json` (its
1104
+ `Paths` is rooted in the experiment), so the same module, planner (`plan_scan`/`run_plan`) and
1105
+ document/page/Markdown APIs serve it (`?exp=NAME` on the dashboard's `/api/conversion/*`).
1106
+ `resolve_target` turns an `index new PATH` argument into a folder (a path starting with a location's
1107
+ name means a folder inside it; an imported collection's name is refused with an explanation).
1108
+
1109
+ **Export/import** (`bundle.py`, stdlib). An export is one `.rag.tgz`: `manifest.json`,
1110
+ `index/_all/{nodes.json,embeddings.npy,merge.manifest.json}`, `index/docs/<doc>/index.meta.json`,
1111
+ `markup/<doc>.md`. The manifest records the embedding model and weights commit, vector size,
1112
+ chunk/tokenizer/index-format versions, conversion settings, document and chunk counts, the
1113
+ description and a SHA-256 per file; `src_path` is reduced to the file name everywhere and
1114
+ `access.json` is never included. Export takes `index.lock` (a consistent workspace) and refuses a
1115
+ collection whose documents disagree on model or chunking. Import, also under `index.lock`:
1116
+ manifest format/version/type check → **model gate** (the configured embedding model must equal
1117
+ the manifest's and, when something is published, the model it was built with -- a switch in
1118
+ progress blocks imports; when both sides know the weights commit it must match too; no partial
1119
+ import) → name
1120
+ check (a location, an indexed or published collection is never overwritten; an
1121
+ earlier import only with `replace`) → unpack into a hidden staging folder in the workspace, refusing
1122
+ anything but plain files at safe relative paths that are listed in the manifest, and verifying every
1123
+ checksum and the `.npy` shape against `nodes.json` (read from the header, no numpy) → assemble the
1124
+ collection folder → write `collection.origin.json` → rename into `index/<name>/` + `markup/<name>/`
1125
+ (an earlier import is parked and put back if either rename fails) → publish. The marker makes
1126
+ indexing skip the collection (no scan, prune, merge or wipe); publish lists it with
1127
+ `origin: imported`; after a later model switch an imported collection blocks publishing with an
1128
+ explanation, as any mixed-model workspace does.
1129
+
1130
+ **Deletion** (`lifecycle.py`, stdlib, administrator: CLI and dashboard, never MCP). Works for every
1131
+ kind of collection. Under `index.lock`: remove `index/<name>/` and `markup/<name>/` (asserted to lie
1132
+ inside the workspace) and nothing else, then publish with `allow_drop`. Source documents are never
1133
+ touched; the access rule, the description and a location's registration are kept, so a collection
1134
+ whose documents are still in place is rebuilt by the next run with its restrictions intact (the
1135
+ result says so in `note`/`sources_remain`). `location remove` = delete + unregister.
1136
+
1137
+ **Inventory** (`inventory.py`, stdlib, administrator view: shows folder paths, so CLI/dashboard only,
1138
+ never MCP). `collection_info(name)` combines: the source folder (reachability; one walk counting
1139
+ supported/unsupported files and bytes -- sources are listed and stat'ed, never opened), the workspace
1140
+ (Markdown and index folders with file counts and sizes, merged vs. per-document index bytes, complete
1141
+ and interrupted per-document indexes, the merge manifest), the published generation (documents,
1142
+ chunks, folder; hard links, so no extra space), the build metadata from `index.meta.json` (model and
1143
+ weights commit, dimensions, chunking, first/last indexed, build/convert/embed time), the newest job
1144
+ that touched the collection (per-status counts and error messages from its event log), documents not
1145
+ indexed yet (with the last run's reason) and documents modified since they were indexed (source mtime
1146
+ newer than `built_at`; no hashing). From these it derives one state: `ok`, `pending`, `unpublished`,
1147
+ `unreachable`, `not_indexed` or `imported`.
1148
+
1149
+ ## 6. The tunables registry (`spec.py`) and `config.json`
1150
+
1151
+ Every knob that shapes a search or an indexing run beyond the small set in §2's `config.json`
1152
+ table -- pool sizes, RRF k, default stages and `top_k` for search; chunk size/overlap and the
1153
+ docling conversion settings for indexing; batch sizes, max sequence length, dtype and device for
1154
+ the models -- is described exactly once, as a `spec.Tunable` in the `spec.TUNABLES` tuple:
1155
+ section (`search`/`indexer`/`models`), key, kind (`int`/`choice`/`text`/`stages`), the `0`/`""`
1156
+ "use the built-in default" sentinel, a label, a one-line "what it means" (always shown), a longer
1157
+ "impact" note (shown behind an info icon), which of three tiers it belongs to, its CLI flag, and,
1158
+ for indexer/model tunables, the environment variable it feeds at the point of use. Every consumer
1159
+ below reads this one list instead of re-describing or re-validating the same knob:
1160
+
1161
+ * **`config.py`** extends `DEFAULTS` with every tunable's key (documentation/defaults only --
1162
+ it does not import `spec`, to avoid a cycle).
1163
+ * **`cli.py`**'s `config set` generates one flag per tunable from `TUNABLES` (`--chunk-size`,
1164
+ `--ocr`, `--embed-batch`, ...) and validates with `spec.validate_section()`, so a new tunable
1165
+ needs no parser change.
1166
+ * **`core/search_daemon.py`** reads `search.*` fresh from `config.json` on every request
1167
+ (`stages`/`retrieval_pool`/`rerank_pool`/`rrf_k`/`top_k` all fall back to it when a caller does
1168
+ not ask for something different), and applies `models.*` to the process environment
1169
+ (`apply_model_env()`, `os.environ.setdefault`, so an already-set real env var always wins) once,
1170
+ right before constructing a real `SearchEngine` -- never for an injected fake one, so tests are
1171
+ unaffected.
1172
+ * **`core/indexer_daemon.py`** reads `indexer.chunk_size`/`chunk_overlap` into each job's spec at
1173
+ `_validate_spec()`, and turns every `indexer.*`/`models.*` tunable with an `env` into a
1174
+ subprocess environment variable for the worker it spawns (`_worker_env()`, same
1175
+ `setdefault` precedence).
1176
+ * **`ui/server.py`** exposes `GET /api/config` (values + the full `tunables()` list from
1177
+ `ui/info.py`) and `POST /api/config/set` (validate + `config.update_config` + nudge the live
1178
+ feed), read by the dashboard's Settings tab (search + indexer tunables) and the Models tab's
1179
+ *Advanced* section (model-runtime tunables) -- both build their form once per tunable
1180
+ (`core.js`'s `tunableField()`/`tunablesForm()`) from the same list `config set --help` uses, and
1181
+ the Search tab's advanced panel shows the same values as placeholders instead of a hardcoded
1182
+ formula, so a debug override is visibly relative to the production default.
1183
+ * **`core/playground.py`** validates a `chunk_size`/`stages` override with the same
1184
+ `spec.parse_stages()`, and `promote_to_production()` (§5.4) validates what it writes back with
1185
+ `spec.validate_section()` -- one registry, one set of rules, whichever surface changes a value.
1186
+ * **`ui/info.py`**'s `config_storage()` (which keys live in which `config.json` section, the precedence built-in
1187
+ default → `config.json` → environment variable, who reads each section) stays in `/api/architecture`; the
1188
+ Architecture tab no longer shows it as a card since 0.9.27 (the Settings tab shows every value and its source).
1189
+
1190
+ **What a value may be (0.9.28).** `spec.LIMITS` gives every whole-number tunable a range (a chunk of 32 to 8,192
1191
+ tokens, a stall limit of one minute to seven days, a batch of at most 1,024 ...); `validate_tunable` refuses a value
1192
+ outside it, and `spec.check_chunking` refuses an overlap of more than half a chunk, checked against the default of
1193
+ whichever of the two is not given (the chunker clamps to the same half, so a value that reaches it another way still
1194
+ makes progress). A `config.json` edited by hand is read through `config._sane`: a value of the wrong kind (text where
1195
+ a number belongs, a negative number, a section that is not an object) is left out, the default is used, and
1196
+ `load_config` returns one message that names each of them; a daemon no longer fails to start on such a file. Keys
1197
+ the program does not know are kept as they are. `update_config` holds the file lock while it reads and writes.
1198
+ `tests/portable/test_tunables.py` walks the registry: every tunable takes its sentinel and its good values and
1199
+ refuses the bad ones, reaches the environment of the process that reads it, loses to a variable that is already
1200
+ set, and is shown with the right source; every `RAG_SEARCH_*` name in the code is in `README.md`; and a launchd
1201
+ service keeps every variable a tunable or a stage reads (`service.PASS_ENV` is derived from the registry; until
1202
+ 0.9.27 it was a list written by hand that lacked the reader, repair, Tesseract, lane and stall variables, so those
1203
+ were lost when the daemons ran as services).
1204
+
1205
+ "When does a change take effect" is one of three tiers (`spec.IMMEDIATE`/`NEXT_RUN`/`RESTART`),
1206
+ shown as a pill next to each tunable and next to each group on the Settings tab:
1207
+
1208
+ | Tier | Meaning | Tunables |
1209
+ |---|---|---|
1210
+ | immediate | applies to the very next search | all of `search.*` |
1211
+ | next-run | applies to the next indexing run; no daemon restart | all of `indexer.*` |
1212
+ | restart | needs `rag-search daemon restart` | all of `models.*` (batch/seq/dtype/device) |
1213
+
1214
+ ## 7. Failure behaviour
1215
+
1216
+ | Situation | Behaviour |
1217
+ |---|---|
1218
+ | search daemon crashes / is killed | next client call restarts it (or launchd does); it loads `serving/current` |
1219
+ | indexer daemon stopped by a signal or `daemon stop` | the reason is logged and stored on the run (`interrupted`); the next start cleans up |
1220
+ | indexer daemon crashes mid-run | worker keeps running until the next daemon start, which kills it and marks the run `interrupted`; rerun is cheap |
1221
+ | worker killed by a signal (e.g. out of memory) | run `failed`, error names the signal (`SIGKILL`) |
1222
+ | a document that cannot be indexed and has not changed (password-protected or damaged PDF, no text at all, the name of another file) | an error of the run that finds it; update runs after that list it as "not tried again" and can end `succeeded`; a complete run tries it again (`outcome.json`, `left_out.json`, see Freshness) |
1223
+ | one document too slow (`RAG_SEARCH_DOC_TIMEOUT`) | that document is an error, the run continues (`partial`), retried next run. The limit is docling's, per call: a whole document, or one run of up to 20 pages of a routed PDF; the document reader has its own limit per page (`RAG_SEARCH_VLM_PAGE_TIMEOUT`) |
1224
+ | a conversion process hangs (a call into native code that never returns: an Apple Vision request, a render of a file a cloud app never delivers) | **stall watch** (`core/stallwatch.py`, 0.9.25): every conversion process says what it does in the event log (`work`, `stage`, `step`, `page`); one with a document open that writes nothing for `indexer.stall_timeout` (default 1 hour, never less than the document timeout plus 15 minutes; `RAG_SEARCH_STALL_TIMEOUT=0` = off) is killed, its document is an error ("stalled: no progress for 60 min on page 12 (document reader) ...", retried next run, pages already read are in the page cache) and the run goes on. Time is counted in observed poll intervals, so a laptop that slept has not stalled. With `jobs=1` (and in the Playground) documents are converted in the run's own process, which can only be ended: the run fails with that message. The indexer daemon is the second line: a run whose event log does not grow at all for twice the limit is stopped and marked `failed` |
1225
+ | a conversion process ends abruptly (killed by the system for memory, a crash in native code, stopped by the stall watch) | Python marks the whole pool broken and fails every waiting document; `indexer._convert_in_pool` keeps the finished documents, puts the rest in a **new pool**, and leaves out only a document that was open in a process that died twice (`STRIKES`), named as the likely cause; at most 8 new pools per run. Before 0.9.25 every document still waiting was reported "worker failed" |
1226
+ | a conversion process cannot exit (docling abandons its OCR / layout threads after a document timeout; one stuck in a native call, e.g. an Apple Vision request, is joined forever by the interpreter) | the pool is closed without waiting (`indexer._shutdown_pool`): every result is already collected, so a process still alive 20 s later is terminated and the run goes on to embedding. The worker and `python -m rag_search.cli` children (Playground runs) end with `worker.leave` (`os._exit` after flushing), so a stuck thread cannot keep a finished run "active" |
1227
+ | worker crashes | run `failed`, nothing published, workspace stays consistent (documents are complete only when `index.meta.json` exists) |
1228
+ | the folder a daemon was started from is deleted (e.g. `install.sh` run inside a release folder that is rebuilt later) | no effect since 0.9.13: long-lived processes start in the data folder. Before, every document of the next run failed (`FileNotFoundError` from `os.getcwd()`, "partially initialized module 'torch'") until the daemons were restarted |
1229
+ | a source is an online-only file of a cloud-storage folder (Box, Google Drive, iCloud, OneDrive under `~/Library/CloudStorage`) | read normally: every rag-search process switches on "fetch online-only files when read" for itself (`paths.allow_cloud_files`, macOS `setiopolicy_np`), which launchd-started daemons do not have by default. Before 0.9.15 each such file failed at once with `[Errno 11] Resource deadlock avoided`. The cloud app downloads the file; if it cannot (app not running, offline) that document is an error with that explanation and is retried next run |
1230
+ | second `start` during a run | returns the active run; `restart` kills and restarts |
1231
+ | bad `config.json` | defaults are used; the error is shown by `doctor` and in `ping` |
1232
+ | models still loading | `list`/`grep` work; `search` says `warming_up` and the client may retry |
1233
+ | generation with another model | followed: its embedder is loaded aside and swapped in with it (an explicitly injected embedder refuses it) |
1234
+ | a model switch that is not finished (some documents on the old model) | `publish` refuses to mix models; the old generation and the old model keep serving; `index new` finishes it |
1235
+ | a model download is interrupted / a model fails its test | the cache resumes; `config.json` is not changed until the test passed |
1236
+ | a search's `stages` drops both `bm25` and `dense`, or names an unknown stage | `spec.parse_stages` rejects it (`bad_request`) before the request reaches the engine or the search lock |
1237
+ | a search asks for the `rerank` stage but the reranker is disabled in `config.json` | reported as `rerank_error` in the reply; the reranker is never lazily loaded to satisfy a per-request override (only a genuinely enabled reranker is ever loaded) |
1238
+ | a search's `retrieval_pool`/`rerank_pool`/`rrf_k` override is unreasonably large | clamped to a fixed ceiling (`spec.RETRIEVAL_POOL_MAX`/`RERANK_POOL_MAX`/`RRF_K_MAX`) regardless of caller, so it can never make one search scan or rerank an unbounded number of candidates |