ragx-cli 0.5.1__tar.gz → 0.6.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 (102) hide show
  1. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/CHANGELOG.md +16 -0
  2. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/PKG-INFO +23 -10
  3. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/README.md +22 -9
  4. ragx_cli-0.6.0/docs/proposal-models-command-interactive-flow.md +131 -0
  5. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/pyproject.toml +1 -1
  6. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/cli/models_cmd.py +72 -40
  7. ragx_cli-0.6.0/src/ragx/cli/models_ui.py +117 -0
  8. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/core/catalog.py +52 -10
  9. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/tests/test_models.py +123 -0
  10. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.agents/skills/zvec/SKILL.md +0 -0
  11. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.agents/skills/zvec/api-cheatsheet.md +0 -0
  12. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.agents/skills/zvec/collection-management/python.md +0 -0
  13. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.agents/skills/zvec/collection-management/typescript.md +0 -0
  14. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.agents/skills/zvec/configuration.md +0 -0
  15. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.agents/skills/zvec/data-model.md +0 -0
  16. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.agents/skills/zvec/data-operations/python.md +0 -0
  17. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.agents/skills/zvec/data-operations/typescript.md +0 -0
  18. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.agents/skills/zvec/embedding.md +0 -0
  19. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.agents/skills/zvec/hybrid-search/python.md +0 -0
  20. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.agents/skills/zvec/hybrid-search/typescript.md +0 -0
  21. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.agents/skills/zvec/multimodal-search/python.md +0 -0
  22. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.agents/skills/zvec/multimodal-search/typescript.md +0 -0
  23. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.agents/skills/zvec/quick-start/python.md +0 -0
  24. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.agents/skills/zvec/quick-start/typescript.md +0 -0
  25. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.agents/skills/zvec/rag-system/python.md +0 -0
  26. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.agents/skills/zvec/rag-system/typescript.md +0 -0
  27. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.agents/skills/zvec/reranker.md +0 -0
  28. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.agents/skills/zvec/troubleshooting.md +0 -0
  29. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.agents/skills/zvec/vector-search/python.md +0 -0
  30. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.agents/skills/zvec/vector-search/typescript.md +0 -0
  31. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.github/workflows/publish.yml +0 -0
  32. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/.gitignore +0 -0
  33. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/AGENTS.md +0 -0
  34. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/CLAUDE.md +0 -0
  35. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/CONTRACTS-PHASE23.md +0 -0
  36. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/CONTRACTS.md +0 -0
  37. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/LICENSE +0 -0
  38. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/docs/feature-temporal-weighting.md +0 -0
  39. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/ragx-cli-plan.md +0 -0
  40. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/research/bge-m3-colbert-multi-vector-late-interaction-for-ragx-rerank-alternative-and-token-level-graph-edges.md +0 -0
  41. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/research/bge-m3-dense-embeddings-as-ragx-provider-multilingual-quality-and-threshold-calibration.md +0 -0
  42. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/research/bge-m3-dense-q8-vs-nomic-q4-benchmark-2026-07-12-worktree-eval-results.md +0 -0
  43. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/research/bge-m3-sparse-lexical-weights-hybrid-retrieval-leg-for-ragx-vs-sqlite-fts5-bm25.md +0 -0
  44. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/research/bge-m3-subchunk-edge-ablation-2026-07-13-ties-again-and-beta-zero-shortlist-mechanism.md +0 -0
  45. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/research/fine-grained-sub-chunk-edges-with-coarse-chunk-nodes-multi-granularity-graph-rag-literature-validation.md +0 -0
  46. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/research/lm-studio-model-download-api-and-curated-embedding-reranker-catalog-for-ragx-models-command.md +0 -0
  47. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/research/subchunk-edge-ablation-2026-07-12-two-round-experiment-protocol-results-and-verdict.md +0 -0
  48. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/skills/ragx-cli/SKILL.md +0 -0
  49. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/skills-lock.json +0 -0
  50. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/__init__.py +0 -0
  51. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/cli/__init__.py +0 -0
  52. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/cli/app.py +0 -0
  53. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/cli/detect.py +0 -0
  54. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/cli/eval_cmd.py +0 -0
  55. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/cli/init_prompt.py +0 -0
  56. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/cli/inspect_cmd.py +0 -0
  57. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/cli/output.py +0 -0
  58. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/cli/pipeline.py +0 -0
  59. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/core/__init__.py +0 -0
  60. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/core/chunking.py +0 -0
  61. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/core/communities.py +0 -0
  62. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/core/config.py +0 -0
  63. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/core/discovery.py +0 -0
  64. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/core/errors.py +0 -0
  65. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/core/eval.py +0 -0
  66. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/core/expansion.py +0 -0
  67. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/core/fusion.py +0 -0
  68. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/core/graph.py +0 -0
  69. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/core/indexer.py +0 -0
  70. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/core/lmstudio.py +0 -0
  71. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/core/models.py +0 -0
  72. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/core/query.py +0 -0
  73. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/core/scoring.py +0 -0
  74. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/core/store.py +0 -0
  75. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/core/traversal.py +0 -0
  76. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/core/vectors.py +0 -0
  77. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/providers/__init__.py +0 -0
  78. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/providers/base.py +0 -0
  79. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/providers/llama_embedder.py +0 -0
  80. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/providers/llama_process.py +0 -0
  81. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/providers/llama_server.py +0 -0
  82. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/providers/openai_compat.py +0 -0
  83. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/providers/registry.py +0 -0
  84. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/src/ragx/providers/st_reranker.py +0 -0
  85. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/tests/__init__.py +0 -0
  86. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/tests/conftest.py +0 -0
  87. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/tests/test_chunking.py +0 -0
  88. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/tests/test_cli.py +0 -0
  89. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/tests/test_communities.py +0 -0
  90. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/tests/test_config_rc.py +0 -0
  91. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/tests/test_discovery.py +0 -0
  92. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/tests/test_eval.py +0 -0
  93. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/tests/test_expansion.py +0 -0
  94. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/tests/test_fusion.py +0 -0
  95. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/tests/test_graph.py +0 -0
  96. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/tests/test_inspect.py +0 -0
  97. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/tests/test_integration.py +0 -0
  98. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/tests/test_llama_server.py +0 -0
  99. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/tests/test_providers.py +0 -0
  100. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/tests/test_store.py +0 -0
  101. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/tests/test_traversal.py +0 -0
  102. {ragx_cli-0.5.1 → ragx_cli-0.6.0}/tests/test_vectors.py +0 -0
@@ -4,6 +4,22 @@ All notable changes to `ragx-cli` are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.6.0] — 2026-07-15
8
+
9
+ ### Changed
10
+
11
+ - **`ragx-cli models` interactive flow redesigned** around context → choice → consequence →
12
+ commit: a machine header (RAM, platform, `llama-server`/`lms` detection) prints first;
13
+ the quality tier is picked from a numbered comparison table (model, size, languages,
14
+ context, license) and the serving engines from numbered menus with one-line tradeoffs —
15
+ recommended defaults marked `»`, invalid input re-prompts; a plan step (download sizes,
16
+ `already downloaded ✓`, config sections, re-index consequence) shows before the confirm.
17
+ Behavior changes: on <8 GB RAM machines an interactive pick of `balanced`/`best` is
18
+ confirmed and then honored instead of silently downgraded (non-interactive runs keep the
19
+ auto-downgrade); `--quality jina-nano` without `--embed-engine` now locks the embedding
20
+ engine to `llama-server` instead of erroring (an explicit `--embed-engine lm-studio`
21
+ still fails loud). Flags, `--json` output, and the non-interactive contract are unchanged.
22
+
7
23
  ## [0.5.0] — 2026-07-15
8
24
 
9
25
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ragx-cli
3
- Version: 0.5.1
3
+ Version: 0.6.0
4
4
  Summary: Similarity-graph RAG CLI: index a corpus into a chunk-level embedding graph and query it
5
5
  Project-URL: Repository, https://github.com/alber70g/ragx-cli
6
6
  Project-URL: Issues, https://github.com/alber70g/ragx-cli/issues
@@ -325,8 +325,19 @@ Full write-ups: `research/bge-m3-dense-q8-vs-nomic-q4-benchmark-2026-07-12-workt
325
325
 
326
326
  `ragx-cli models` recommends an embedding + reranker combo, downloads the embedding model
327
327
  **through LM Studio** (`lms get` — one download path, plays well with restricted networks),
328
- pre-fetches the reranker from Hugging Face, and writes both to `ragx.toml`. Interactive on a
329
- TTY (quality tier + engine questions, RAM auto-detected); scriptable via flags:
328
+ pre-fetches the reranker from Hugging Face, and writes both to `ragx.toml`. On a TTY it walks
329
+ three steps — context first, consequences before anything is written:
330
+
331
+ 1. **machine header** — detected RAM, platform, whether `llama-server` is on PATH and the
332
+ `lms` CLI was found (this is what drives the recommended defaults);
333
+ 2. **numbered menus** — the embedding tier table (model, size, languages, context, license)
334
+ and one menu per serving engine with a one-line tradeoff each; the recommended default is
335
+ marked `»` (Enter accepts), invalid input re-prompts;
336
+ 3. **plan** — what will be downloaded (or `already downloaded ✓`) with approximate sizes,
337
+ which config sections change, and whether a re-index is required — all shown *before*
338
+ the final confirm.
339
+
340
+ Scriptable via flags:
330
341
 
331
342
  ```bash
332
343
  ragx-cli models # interactive
@@ -336,12 +347,12 @@ ragx-cli models --quality fast --dry-run # recommend only, change
336
347
 
337
348
  The curated catalog (every entry verified working end-to-end):
338
349
 
339
- | tier | embedding model | notes |
340
- |---|---|---|
341
- | `fast` | EmbeddingGemma 300M | 308M, 100+ languages, 2048-token context |
342
- | `balanced` | BGE-M3 | ragx's benchmarked production model (~100 languages) |
343
- | `best` | Qwen3-Embedding-0.6B | 100+ languages, 32K context |
344
- | `jina-nano` | Jina embeddings v5 nano | 239M, best-in-class sub-500M; **CC-BY-NC (non-commercial)**; llama-server engine only — LM Studio downloads it but can't serve EuroBERT |
350
+ | tier | embedding model | size | langs | ctx | notes |
351
+ |---|---|---|---|---|---|
352
+ | `fast` | EmbeddingGemma 300M | 308M | 100+ | 2K | lightest; small corpora, low RAM |
353
+ | `balanced` | BGE-M3 | 568M | ~100 | 8K | ragx's benchmarked production model |
354
+ | `best` | Qwen3-Embedding-0.6B | 0.6B | 100+ | 32K | highest quality; slower indexing |
355
+ | `jina-nano` | Jina embeddings v5 nano | 239M | multi | 8K | best sub-500M; **CC-BY-NC (non-commercial)**; llama-server engine only — LM Studio downloads it but can't serve EuroBERT (picking it locks the embedding engine to llama-server automatically; an explicit `--embed-engine lm-studio` still fails loud) |
345
356
 
346
357
  **Serving engines.** Embeddings run through `--embed-engine`: **`llama-server`** (default when
347
358
  llama.cpp is installed — ragx auto-spawns `llama-server --embedding` on the downloaded GGUF and
@@ -359,7 +370,9 @@ terminates it at exit; LM Studio is only the downloader) or **`lm-studio`** (LM
359
370
 
360
371
  With both engines on `llama-server` the whole stack is ragx-managed: LM Studio downloads the
361
372
  GGUFs, ragx spawns and reaps the servers, and neither LM Studio nor huggingface.co is needed
362
- at query time. Under 8 GB RAM the embedding tier drops to `fast`; on macOS
373
+ at query time. Under 8 GB RAM the recommended default moves to `fast`: interactively, picking
374
+ a heavier tier asks for confirmation and then **honors your choice**; non-interactive runs
375
+ (`--yes`/piped) auto-downgrade to `fast` with a note, since there is nobody to ask. On macOS
363
376
  `lms get --yes` picks MLX variants where LM Studio's catalog offers them. Switching the
364
377
  embedding model invalidates the index — the command tells you to run `ragx-cli index --full`.
365
378
  `init` offers this flow as its final step; rerun `ragx-cli models` anytime to switch.
@@ -297,8 +297,19 @@ Full write-ups: `research/bge-m3-dense-q8-vs-nomic-q4-benchmark-2026-07-12-workt
297
297
 
298
298
  `ragx-cli models` recommends an embedding + reranker combo, downloads the embedding model
299
299
  **through LM Studio** (`lms get` — one download path, plays well with restricted networks),
300
- pre-fetches the reranker from Hugging Face, and writes both to `ragx.toml`. Interactive on a
301
- TTY (quality tier + engine questions, RAM auto-detected); scriptable via flags:
300
+ pre-fetches the reranker from Hugging Face, and writes both to `ragx.toml`. On a TTY it walks
301
+ three steps — context first, consequences before anything is written:
302
+
303
+ 1. **machine header** — detected RAM, platform, whether `llama-server` is on PATH and the
304
+ `lms` CLI was found (this is what drives the recommended defaults);
305
+ 2. **numbered menus** — the embedding tier table (model, size, languages, context, license)
306
+ and one menu per serving engine with a one-line tradeoff each; the recommended default is
307
+ marked `»` (Enter accepts), invalid input re-prompts;
308
+ 3. **plan** — what will be downloaded (or `already downloaded ✓`) with approximate sizes,
309
+ which config sections change, and whether a re-index is required — all shown *before*
310
+ the final confirm.
311
+
312
+ Scriptable via flags:
302
313
 
303
314
  ```bash
304
315
  ragx-cli models # interactive
@@ -308,12 +319,12 @@ ragx-cli models --quality fast --dry-run # recommend only, change
308
319
 
309
320
  The curated catalog (every entry verified working end-to-end):
310
321
 
311
- | tier | embedding model | notes |
312
- |---|---|---|
313
- | `fast` | EmbeddingGemma 300M | 308M, 100+ languages, 2048-token context |
314
- | `balanced` | BGE-M3 | ragx's benchmarked production model (~100 languages) |
315
- | `best` | Qwen3-Embedding-0.6B | 100+ languages, 32K context |
316
- | `jina-nano` | Jina embeddings v5 nano | 239M, best-in-class sub-500M; **CC-BY-NC (non-commercial)**; llama-server engine only — LM Studio downloads it but can't serve EuroBERT |
322
+ | tier | embedding model | size | langs | ctx | notes |
323
+ |---|---|---|---|---|---|
324
+ | `fast` | EmbeddingGemma 300M | 308M | 100+ | 2K | lightest; small corpora, low RAM |
325
+ | `balanced` | BGE-M3 | 568M | ~100 | 8K | ragx's benchmarked production model |
326
+ | `best` | Qwen3-Embedding-0.6B | 0.6B | 100+ | 32K | highest quality; slower indexing |
327
+ | `jina-nano` | Jina embeddings v5 nano | 239M | multi | 8K | best sub-500M; **CC-BY-NC (non-commercial)**; llama-server engine only — LM Studio downloads it but can't serve EuroBERT (picking it locks the embedding engine to llama-server automatically; an explicit `--embed-engine lm-studio` still fails loud) |
317
328
 
318
329
  **Serving engines.** Embeddings run through `--embed-engine`: **`llama-server`** (default when
319
330
  llama.cpp is installed — ragx auto-spawns `llama-server --embedding` on the downloaded GGUF and
@@ -331,7 +342,9 @@ terminates it at exit; LM Studio is only the downloader) or **`lm-studio`** (LM
331
342
 
332
343
  With both engines on `llama-server` the whole stack is ragx-managed: LM Studio downloads the
333
344
  GGUFs, ragx spawns and reaps the servers, and neither LM Studio nor huggingface.co is needed
334
- at query time. Under 8 GB RAM the embedding tier drops to `fast`; on macOS
345
+ at query time. Under 8 GB RAM the recommended default moves to `fast`: interactively, picking
346
+ a heavier tier asks for confirmation and then **honors your choice**; non-interactive runs
347
+ (`--yes`/piped) auto-downgrade to `fast` with a note, since there is nobody to ask. On macOS
335
348
  `lms get --yes` picks MLX variants where LM Studio's catalog offers them. Switching the
336
349
  embedding model invalidates the index — the command tells you to run `ragx-cli index --full`.
337
350
  `init` offers this flow as its final step; rerun `ragx-cli models` anytime to switch.
@@ -0,0 +1,131 @@
1
+ # Proposal: `ragx-cli models` interactive flow redesign
2
+
3
+ **Status:** implemented 2026-07-15 (`cli/models_ui.py` + `cli/models_cmd.py`); kept as the
4
+ design rationale. The open decision below was resolved as proposed: warn-and-honor
5
+ interactively, auto-downgrade non-interactively.
6
+
7
+ ## Problem
8
+
9
+ The current interactive flow (`src/ragx/cli/models_cmd.py:62-104`) asks all three
10
+ questions **before** showing any of the information needed to answer them:
11
+
12
+ 1. **Blind prompts.** `quality (fast/balanced/best/jina-nano) [balanced]:` offers four
13
+ opaque words. The catalog knows each tier's model name, parameter count, languages,
14
+ context length, and license — none of it is shown at prompt time. Machine specs print
15
+ *after* the answers.
16
+ 2. **Engine prompts don't state tradeoffs.** "llama-server vs sentence-transformers"
17
+ means nothing unless you already know one avoids huggingface.co entirely and the
18
+ other needs the `[rerank]` extra. That context lives only in `--help` flag text.
19
+ 3. **Silent downgrade.** Pick `best` on a <8 GB machine and `recommend()`
20
+ (`catalog.py:133-135`) substitutes `fast`, telling you only afterwards. No way to
21
+ override or reconsider.
22
+ 4. **The jina-nano trap.** Choosing `jina-nano` with the default `lm-studio` embed
23
+ engine hard-errors at the end ("rerun with `--embed-engine llama-server`") — even
24
+ though the catalog already encodes the constraint (`requires_llama_server`) and
25
+ could drive the default.
26
+ 5. **Fragile input.** A typo in the free-text tier name raises `RagxError`, exit 2,
27
+ start over.
28
+ 6. **Consequences surface too late.** Download sizes are never shown; "your index is
29
+ now invalid, run `index --full`" prints *after* `ragx.toml` is written.
30
+
31
+ ## Proposed flow
32
+
33
+ Principle: **context → choice → consequence → commit.** Detect everything first, show
34
+ it, ask with numbered menus, and present a plan before touching anything.
35
+
36
+ ```
37
+ $ ragx-cli models
38
+
39
+ machine: 32 GB RAM · macOS · llama-server: on PATH · LM Studio (lms): found
40
+
41
+ ── Step 1/3 · embedding model ─────────────────────────────────────────
42
+ Determines retrieval quality. Changing it later invalidates the
43
+ index (full re-index required).
44
+
45
+ # tier model size langs ctx notes
46
+ 1 fast EmbeddingGemma 300M 308M 100+ 2K lightest; small corpora, low RAM
47
+ » 2 balanced BGE-M3 568M ~100 8K ragx's benchmarked production model
48
+ 3 best Qwen3-Embedding-0.6B 0.6B 100+ 32K highest quality; slower indexing
49
+ 4 jina-nano Jina v5 nano 239M multi 8K best <500M quality · CC-BY-NC
50
+ (non-commercial) · llama-server only
51
+ select 1-4 [2]:
52
+
53
+ ── Step 2/3 · engines ─────────────────────────────────────────────────
54
+ embedding serving:
55
+ » 1 llama-server ragx auto-spawns llama.cpp; LM Studio only
56
+ downloads (detected on PATH)
57
+ 2 lm-studio LM Studio app must be running to serve
58
+ select 1-2 [1]:
59
+
60
+ reranker:
61
+ » 1 llama-server GGUF via LM Studio — no huggingface.co needed
62
+ 2 sentence-transformers downloads from Hugging Face; needs the
63
+ ragx-cli[rerank] extra
64
+ select 1-2 [1]:
65
+
66
+ ── Step 3/3 · plan ────────────────────────────────────────────────────
67
+ embedding BGE-M3 Q8 GGUF (~600 MB) already downloaded ✓
68
+ reranker bge-reranker-v2-m3 Q8 (~600 MB) will download via LM Studio
69
+ config ragx.toml → embeddings.*, rerank.* (7 keys)
70
+ re-index REQUIRED — embedding model changes; run `ragx-cli index --full` after
71
+
72
+ proceed? [Y/n]:
73
+ ```
74
+
75
+ ### How each problem maps to a fix
76
+
77
+ - **Specs shown first**, including whether `llama-server` was found on PATH — the
78
+ engine defaults stop being invisible magic.
79
+ - **Numbered menus with a re-prompt loop** instead of one-shot free text. `»` marks
80
+ the recommended default; Enter accepts it. Invalid input re-prompts instead of
81
+ exiting.
82
+ - **RAM guard becomes visible, not substituting.** On a <8 GB machine the `»` marker
83
+ moves to `fast` and heavy rows get a `⚠ needs ≥8 GB` annotation. If the user still
84
+ picks `best`, honor it after a one-line confirm — a balanced choice means respecting
85
+ an informed override. Non-interactive `--quality best` keeps today's auto-downgrade
86
+ + note (an agent can't be asked); this interactive/non-interactive asymmetry should
87
+ be stated in the README.
88
+ - **jina-nano stops being a trap:** picking it prints
89
+ `→ embedding engine locked to llama-server (LM Studio cannot serve this model)` and
90
+ skips that question instead of erroring at the end.
91
+ - **The plan step** shows download sizes, marks what is already downloaded (the
92
+ `_ensure_downloaded` check moves before the confirm), lists which config keys
93
+ change, and states the re-index consequence *before* the write.
94
+
95
+ ## Implementation sketch
96
+
97
+ Small and contained — no behavior change for flags / `--json` / non-interactive:
98
+
99
+ 1. **`core/catalog.py`** — add structured fields to the choices (`params`, `context`,
100
+ `languages`, `download_mb`) instead of parsing prose out of `notes`.
101
+ Verify: existing tests pass; table renders from fields.
102
+ 2. **`cli/models_cmd.py`** — a `_pick_numbered(title, rows, default)` helper
103
+ (validation loop, `»` marker) replacing `typer.prompt`/`_pick_engine` in
104
+ interactive mode; reorder so specs + `lms`/`llama-server` detection and the
105
+ already-downloaded check happen before any question.
106
+ Verify: interactive smoke run against a scratch corpus.
107
+ 3. **`recommend()`** — split recommendation from enforcement: return annotations
108
+ (RAM warning, license note, engine lock) rather than silently swapping tiers in
109
+ interactive mode.
110
+ Verify: unit tests for low-RAM interactive vs non-interactive paths.
111
+ 4. **README** — update the `models` section in the same change (CLAUDE.md rule:
112
+ usage-visible changes ship with their docs).
113
+
114
+ Estimated diff: ~150 lines, centered on `models_cmd.py`.
115
+
116
+ ### Rendering choice
117
+
118
+ Plain f-string column alignment is enough. `rich` is already available (transitive
119
+ dependency of typer ≥0.12) if fancier tables are ever wanted, but no new dependency
120
+ and no speculative widgets now.
121
+
122
+ ### Deliberate non-goal
123
+
124
+ No full TUI (arrow-key selection à la questionary). Numbered menus keep the code
125
+ testable with piped stdin and keep the agent-first stdin/stdout contract intact.
126
+
127
+ ## Open decision
128
+
129
+ Interactive RAM-override behavior: warn-and-honor (proposed) vs keep the current
130
+ auto-downgrade everywhere. Proposal argues warn-and-honor interactively, downgrade
131
+ non-interactively.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ragx-cli"
3
- version = "0.5.1"
3
+ version = "0.6.0"
4
4
  description = "Similarity-graph RAG CLI: index a corpus into a chunk-level embedding graph and query it"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -4,11 +4,13 @@ onto the main app by app.py; `init` routes here as an optional final step."""
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
+ import shutil
7
8
  import sys
8
9
  from pathlib import Path
9
10
 
10
11
  import typer
11
12
 
13
+ from ragx.cli import models_ui as ui
12
14
  from ragx.cli.output import emit_json, fail, migrate_confirm
13
15
  from ragx.core import lmstudio
14
16
  from ragx.core.catalog import QUALITY_TIERS, detect_specs, recommend
@@ -61,27 +63,40 @@ def run_flow(
61
63
  """The actual flow, callable from `init`. Raises RagxError on failure."""
62
64
  cfg = Config.load(root, confirm=migrate_confirm())
63
65
  interactive = sys.stdin.isatty() and not yes
66
+ specs = detect_specs()
67
+ llama_found = shutil.which("llama-server") is not None
68
+ lms = lmstudio.find_lms() if (interactive or not dry_run) else None
69
+ ui.header(specs, llama_found, lms_found=None if (dry_run and not interactive) else lms is not None)
70
+
64
71
  if quality is None:
65
72
  if not interactive:
66
73
  raise RagxError(f"--quality required when not interactive ({'/'.join(QUALITY_TIERS)})")
67
- quality = typer.prompt(f" quality ({'/'.join(QUALITY_TIERS)})", default="balanced")
68
- engine = _pick_engine(
69
- "rerank engine", rerank_engine, ("llama-server", "sentence-transformers"),
70
- interactive, fallback="sentence-transformers",
71
- )
72
- emb_engine = _pick_engine(
73
- "embedding engine", embed_engine, ("llama-server", "lm-studio"),
74
- interactive, fallback="lm-studio",
75
- )
74
+ quality = ui.pick_tier(specs)
75
+ embedding, reranker, notes = recommend(quality, specs, enforce_ram=not interactive)
76
76
 
77
- specs = detect_specs()
78
- embedding, reranker, notes = recommend(quality, specs)
79
- if embedding.requires_llama_server and emb_engine != "llama-server":
80
- raise RagxError(
81
- f"{embedding.label} can only be served by the llama-server engine "
82
- "(LM Studio downloads it but cannot serve EuroBERT models) — "
83
- "rerun with --embed-engine llama-server"
77
+ if embedding.requires_llama_server and embed_engine != "llama-server":
78
+ if embed_engine is not None:
79
+ raise RagxError(
80
+ f"{embedding.label} can only be served by the llama-server engine "
81
+ "(LM Studio downloads it but cannot serve EuroBERT models) — "
82
+ "rerun with --embed-engine llama-server"
83
+ )
84
+ emb_engine = "llama-server"
85
+ lock_note = f"embedding engine locked to llama-server — LM Studio cannot serve {embedding.label}"
86
+ if interactive:
87
+ ui.echo(f"\n → {lock_note}")
88
+ else:
89
+ notes.append(lock_note)
90
+ else:
91
+ emb_engine = _resolve_engine(
92
+ "embedding engine", embed_engine, ui.EMBED_ENGINES,
93
+ interactive, llama_found, fallback="lm-studio",
84
94
  )
95
+ engine = _resolve_engine(
96
+ "rerank engine", rerank_engine, ui.RERANK_ENGINES,
97
+ interactive, llama_found, fallback="sentence-transformers",
98
+ )
99
+
85
100
  engine_desc = (
86
101
  "llama.cpp llama-server, GGUF downloaded via LM Studio — no huggingface.co needed"
87
102
  if engine == "llama-server"
@@ -90,8 +105,8 @@ def run_flow(
90
105
  emb_desc = (
91
106
  "llama-server auto-spawned by ragx" if emb_engine == "llama-server" else "served by LM Studio"
92
107
  )
93
- _echo(f"machine: {specs.ram_gb:.0f} GB RAM, {'macOS' if specs.macos else sys.platform}")
94
- _echo(f"embedding: {embedding.label} ({embedding.notes}) — {emb_desc}")
108
+ spec_line = f"{embedding.params}, {embedding.languages} languages, {embedding.context} context"
109
+ _echo(f"embedding: {embedding.label} ({spec_line}) — {emb_desc}")
95
110
  _echo(f"reranker: {reranker.label} ({reranker.notes}) — via {engine_desc}")
96
111
  for note in notes:
97
112
  _echo(f"note: {note}")
@@ -100,23 +115,39 @@ def run_flow(
100
115
  _finish(json_out, embedding, reranker, notes, engine=engine, emb_engine=emb_engine,
101
116
  downloaded=False, config_updated=False, reindex_required=False)
102
117
  return
103
- if interactive and not typer.confirm("download and update ragx.toml?", default=True):
104
- raise typer.Exit(code=0)
105
118
 
106
- lms = lmstudio.find_lms()
107
119
  if lms is None:
108
120
  raise RagxError(INSTALL_HINT)
109
121
 
122
+ emb_frag = embedding.gguf_fragment if emb_engine == "llama-server" else embedding.repo_fragment
123
+ emb_pre = lmstudio.find_installed(lms, emb_frag)
124
+ rr_pre = lmstudio.find_installed(lms, reranker.gguf_fragment) if engine == "llama-server" else None
125
+
126
+ if interactive:
127
+ if emb_pre is not None and emb_pre.model_key == cfg.get("embeddings.model"):
128
+ reindex_line = "not needed — embedding model unchanged"
129
+ else:
130
+ reindex_line = "REQUIRED — embedding model changes; run `ragx-cli index --full` after"
131
+ ui.show_plan(embedding=embedding, reranker=reranker, emb_engine=emb_engine,
132
+ rerank_engine=engine, emb_installed=emb_pre is not None,
133
+ rr_installed=rr_pre is not None, reindex_line=reindex_line)
134
+ if not typer.confirm(" proceed — download and update ragx.toml?", default=True, err=True):
135
+ raise typer.Exit(code=0)
136
+
110
137
  embed_updates: list[tuple[str, str]]
111
138
  if emb_engine == "llama-server":
112
- installed, downloaded = _ensure_downloaded(lms, embedding.gguf_ref, embedding.gguf_fragment)
139
+ installed, downloaded = _ensure_downloaded(
140
+ lms, embedding.gguf_ref, embedding.gguf_fragment, pre=emb_pre
141
+ )
113
142
  embed_updates = [
114
143
  ("embeddings.provider", "llama-server"),
115
144
  ("embeddings.gguf", str(lmstudio.models_root() / installed.path)),
116
145
  ("embeddings.base_url", "http://127.0.0.1:9813/v1"),
117
146
  ]
118
147
  else:
119
- installed, downloaded = _ensure_downloaded(lms, embedding.ref, embedding.repo_fragment)
148
+ installed, downloaded = _ensure_downloaded(
149
+ lms, embedding.ref, embedding.repo_fragment, pre=emb_pre
150
+ )
120
151
  embed_updates = [
121
152
  ("embeddings.provider", "openai"),
122
153
  ("embeddings.gguf", ""),
@@ -125,7 +156,9 @@ def run_flow(
125
156
 
126
157
  rerank_updates: list[tuple[str, str]]
127
158
  if engine == "llama-server":
128
- gguf_model, gguf_downloaded = _ensure_downloaded(lms, reranker.gguf_ref, reranker.gguf_fragment)
159
+ gguf_model, gguf_downloaded = _ensure_downloaded(
160
+ lms, reranker.gguf_ref, reranker.gguf_fragment, pre=rr_pre
161
+ )
129
162
  downloaded = downloaded or gguf_downloaded
130
163
  gguf_path = str(lmstudio.models_root() / gguf_model.path)
131
164
  rerank_updates = [("rerank.provider", "llama-server"), ("rerank.gguf", gguf_path),
@@ -156,29 +189,28 @@ def run_flow(
156
189
  model_key=installed.model_key)
157
190
 
158
191
 
159
- def _pick_engine(
160
- label: str, value: str | None, engines: tuple[str, str], interactive: bool, *, fallback: str
192
+ def _resolve_engine(
193
+ label: str, value: str | None, options: tuple[tuple[str, str], ...],
194
+ interactive: bool, llama_found: bool, *, fallback: str,
161
195
  ) -> str:
162
- """Explicit flag wins; otherwise default to llama-server when the binary is present
163
- (fully ragx-managed, no huggingface.co), falling back to `fallback`."""
196
+ """Explicit flag wins; interactive shows a numbered menu; otherwise default to
197
+ llama-server when the binary is present (fully ragx-managed, no huggingface.co),
198
+ falling back to `fallback`."""
199
+ names = tuple(name for name, _ in options)
164
200
  if value is not None:
165
- if value not in engines:
166
- raise RagxError(f"{label} must be one of {'/'.join(engines)}")
201
+ if value not in names:
202
+ raise RagxError(f"{label} must be one of {'/'.join(names)}")
167
203
  return value
168
- import shutil
169
-
170
- default = "llama-server" if shutil.which("llama-server") else fallback
204
+ default = "llama-server" if llama_found else fallback
171
205
  if interactive:
172
- picked = typer.prompt(f" {label} ({'/'.join(engines)})", default=default)
173
- if picked not in engines:
174
- raise RagxError(f"{label} must be one of {'/'.join(engines)}")
175
- return picked
206
+ return ui.pick_engine(label, options, default, llama_found)
176
207
  return default
177
208
 
178
209
 
179
- def _ensure_downloaded(lms: str, ref: str, fragment: str):
180
- """Download `ref` via LM Studio unless a model matching `fragment` is already there."""
181
- installed = lmstudio.find_installed(lms, fragment)
210
+ def _ensure_downloaded(lms: str, ref: str, fragment: str, pre: lmstudio.InstalledModel | None = None):
211
+ """Download `ref` via LM Studio unless a model matching `fragment` is already there
212
+ (`pre` = an earlier find_installed result to reuse)."""
213
+ installed = pre if pre is not None else lmstudio.find_installed(lms, fragment)
182
214
  if installed is not None:
183
215
  _echo(f"already downloaded: {installed.model_key}")
184
216
  return installed, False
@@ -0,0 +1,117 @@
1
+ """Interactive presentation for `ragx-cli models`: machine header, tier table,
2
+ numbered pickers, and the pre-confirm plan. Rendering + prompting only — no
3
+ downloads, no config writes. Everything goes to stderr (stdout stays pure for
4
+ --json)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+
10
+ import typer
11
+
12
+ from ragx.core.catalog import EMBEDDINGS, EmbeddingChoice, RerankerChoice, Specs, ram_limited
13
+
14
+ EMBED_ENGINES = (
15
+ ("llama-server", "ragx auto-spawns llama.cpp; LM Studio only downloads"),
16
+ ("lm-studio", "LM Studio app must be running to serve embeddings"),
17
+ )
18
+ RERANK_ENGINES = (
19
+ ("llama-server", "GGUF via LM Studio — no huggingface.co access needed"),
20
+ ("sentence-transformers", "downloads from Hugging Face; needs the ragx-cli[rerank] extra"),
21
+ )
22
+
23
+
24
+ def echo(msg: str = "") -> None:
25
+ typer.echo(msg, err=True)
26
+
27
+
28
+ def header(specs: Specs, llama_found: bool, lms_found: bool | None) -> None:
29
+ """One line of detected facts; lms_found=None means it wasn't looked up (dry-run)."""
30
+ ram = f"{specs.ram_gb:.0f} GB RAM" if specs.ram_gb else "RAM unknown"
31
+ parts = [
32
+ ram,
33
+ "macOS" if specs.macos else sys.platform,
34
+ f"llama-server: {'on PATH' if llama_found else 'not found'}",
35
+ ]
36
+ if lms_found is not None:
37
+ parts.append(f"LM Studio (lms): {'found' if lms_found else 'NOT FOUND'}")
38
+ echo("machine: " + " · ".join(parts))
39
+
40
+
41
+ def _rule(title: str) -> None:
42
+ echo()
43
+ echo(f"── {title} " + "─" * max(4, 58 - len(title)))
44
+
45
+
46
+ def pick_numbered(rows: list[str], default: int) -> int:
47
+ """Numbered menu (`»` marks the default); loops until a valid 1-based pick."""
48
+ for i, row in enumerate(rows, 1):
49
+ echo(f" {'»' if i == default else ' '} {i} {row}")
50
+ while True:
51
+ raw = typer.prompt(f" select 1-{len(rows)}", default=str(default), err=True).strip()
52
+ if raw.isdigit() and 1 <= int(raw) <= len(rows):
53
+ return int(raw)
54
+ echo(f" enter a number between 1 and {len(rows)}")
55
+
56
+
57
+ def pick_tier(specs: Specs) -> str:
58
+ limited = ram_limited(specs)
59
+ tiers = list(EMBEDDINGS)
60
+ _rule("embedding model")
61
+ echo(" determines retrieval quality — changing it later requires a full re-index")
62
+ echo()
63
+ echo(f" # {'tier':<10} {'model':<24} {'size':<5} {'langs':<6} {'ctx':<4} notes")
64
+ rows = []
65
+ for name in tiers:
66
+ e = EMBEDDINGS[name]
67
+ note = e.notes + (" ⚠ needs ≥8 GB RAM" if limited and name in ("balanced", "best") else "")
68
+ rows.append(f"{e.tier:<10} {e.label:<24} {e.params:<5} {e.languages:<6} {e.context:<4} {note}")
69
+ default = tiers.index("fast" if limited else "balanced") + 1
70
+ while True:
71
+ tier = tiers[pick_numbered(rows, default) - 1]
72
+ if limited and tier in ("balanced", "best") and not typer.confirm(
73
+ f" only {specs.ram_gb:.0f} GB RAM detected — {tier} may be slow here; use it anyway?",
74
+ default=False,
75
+ err=True,
76
+ ):
77
+ continue
78
+ return tier
79
+
80
+
81
+ def pick_engine(label: str, options: tuple[tuple[str, str], ...], default: str, llama_found: bool) -> str:
82
+ _rule(label)
83
+ names = [name for name, _ in options]
84
+ rows = [
85
+ f"{name:<22} {desc}" + (" (detected on PATH)" if name == "llama-server" and llama_found else "")
86
+ for name, desc in options
87
+ ]
88
+ return names[pick_numbered(rows, names.index(default) + 1) - 1]
89
+
90
+
91
+ def show_plan(
92
+ *,
93
+ embedding: EmbeddingChoice,
94
+ reranker: RerankerChoice,
95
+ emb_engine: str,
96
+ rerank_engine: str,
97
+ emb_installed: bool,
98
+ rr_installed: bool,
99
+ reindex_line: str,
100
+ ) -> None:
101
+ _rule("plan")
102
+ emb_status = (
103
+ "already downloaded ✓" if emb_installed
104
+ else f"will download via LM Studio (~{embedding.download_mb} MB)"
105
+ )
106
+ if rerank_engine == "llama-server":
107
+ rr_status = (
108
+ "already downloaded ✓" if rr_installed
109
+ else f"will download via LM Studio (~{reranker.download_mb} MB)"
110
+ )
111
+ else:
112
+ rr_status = f"downloads from Hugging Face (~{reranker.download_mb} MB)"
113
+ echo(f" embedding {embedding.label:<26} via {emb_engine:<22} {emb_status}")
114
+ echo(f" reranker {reranker.label:<26} via {rerank_engine:<22} {rr_status}")
115
+ echo(" config ragx.toml → embeddings.*, rerank.*")
116
+ echo(f" re-index {reindex_line}")
117
+ echo()
@@ -18,6 +18,8 @@ from ragx.core.errors import RagxError
18
18
 
19
19
  QUALITY_TIERS = ("fast", "balanced", "best", "jina-nano")
20
20
 
21
+ LOW_RAM_GB = 8.0
22
+
21
23
 
22
24
  @dataclass(frozen=True)
23
25
  class EmbeddingChoice:
@@ -27,7 +29,11 @@ class EmbeddingChoice:
27
29
  repo_fragment: str # substring that identifies it in `lms ls --json` output
28
30
  doc_prefix: str
29
31
  query_prefix: str
30
- notes: str
32
+ params: str # parameter count, for display ("308M")
33
+ languages: str # language coverage, for display ("100+")
34
+ context: str # context window, for display ("2K")
35
+ download_mb: int # approximate download size shown in the plan step
36
+ notes: str # short qualitative note for the interactive picker table
31
37
  # llama-server engine: explicit GGUF repo (the catalog-id ref may resolve to MLX,
32
38
  # which llama-server can't load) + the fragment locating the downloaded .gguf file
33
39
  gguf_ref: str
@@ -42,6 +48,7 @@ class RerankerChoice:
42
48
  label: str
43
49
  model: str # HF repo id, loadable by sentence-transformers CrossEncoder
44
50
  languages: str
51
+ download_mb: int # approximate download size shown in the plan step
45
52
  notes: str
46
53
  # llama-server engine: Q8_0 GGUF downloadable via LM Studio (validated against the
47
54
  # safetensors originals — scores within ~0.5 logit, identical ordering)
@@ -57,7 +64,11 @@ EMBEDDINGS: dict[str, EmbeddingChoice] = {
57
64
  repo_fragment="embeddinggemma",
58
65
  doc_prefix="title: none | text: ",
59
66
  query_prefix="task: search result | query: ",
60
- notes="308M, 100+ languages, 2048-token context",
67
+ params="308M",
68
+ languages="100+",
69
+ context="2K",
70
+ download_mb=300,
71
+ notes="lightest; small corpora, low RAM",
61
72
  gguf_ref="https://huggingface.co/lmstudio-community/embeddinggemma-300m-qat-GGUF",
62
73
  gguf_fragment="embeddinggemma-300m-qat",
63
74
  ),
@@ -68,7 +79,11 @@ EMBEDDINGS: dict[str, EmbeddingChoice] = {
68
79
  repo_fragment="bge-m3",
69
80
  doc_prefix="",
70
81
  query_prefix="",
71
- notes="568M, ~100 languages, 8192-token context; ragx's benchmarked production model",
82
+ params="568M",
83
+ languages="~100",
84
+ context="8K",
85
+ download_mb=600,
86
+ notes="ragx's benchmarked production model",
72
87
  gguf_ref="https://huggingface.co/gaianet/bge-m3-GGUF",
73
88
  gguf_fragment="bge-m3",
74
89
  ),
@@ -82,7 +97,11 @@ EMBEDDINGS: dict[str, EmbeddingChoice] = {
82
97
  "Instruct: Given a web search query, retrieve relevant passages that answer "
83
98
  "the query\nQuery: "
84
99
  ),
85
- notes="0.6B, 100+ languages, 32K context",
100
+ params="0.6B",
101
+ languages="100+",
102
+ context="32K",
103
+ download_mb=640,
104
+ notes="highest quality; slower indexing",
86
105
  gguf_ref="https://huggingface.co/Qwen/Qwen3-Embedding-0.6B-GGUF",
87
106
  gguf_fragment="qwen3-embedding-0.6b",
88
107
  ),
@@ -93,7 +112,11 @@ EMBEDDINGS: dict[str, EmbeddingChoice] = {
93
112
  repo_fragment="v5-nano-retrieval-q8_0",
94
113
  doc_prefix="Document: ",
95
114
  query_prefix="Query: ",
96
- notes="239M, best-in-class sub-500M quality; CC-BY-NC license (non-commercial use only)",
115
+ params="239M",
116
+ languages="multi",
117
+ context="8K",
118
+ download_mb=250,
119
+ notes="best sub-500M quality; CC-BY-NC (non-commercial); llama-server only",
97
120
  gguf_ref="https://huggingface.co/jinaai/jina-embeddings-v5-text-nano-retrieval-GGUF@Q8_0",
98
121
  gguf_fragment="v5-nano-retrieval-q8_0",
99
122
  requires_llama_server=True,
@@ -104,6 +127,7 @@ RERANKER = RerankerChoice(
104
127
  label="BGE reranker v2-m3",
105
128
  model="BAAI/bge-reranker-v2-m3",
106
129
  languages="multilingual",
130
+ download_mb=600,
107
131
  notes="568M, multilingual; keeps the measured Dutch-query recall win",
108
132
  gguf_ref="https://huggingface.co/gpustack/bge-reranker-v2-m3-GGUF@Q8_0",
109
133
  gguf_fragment="bge-reranker-v2-m3-q8_0",
@@ -124,15 +148,33 @@ def detect_specs() -> Specs:
124
148
  return Specs(ram_gb=ram, macos=sys.platform == "darwin")
125
149
 
126
150
 
127
- def recommend(quality: str, specs: Specs) -> tuple[EmbeddingChoice, RerankerChoice, list[str]]:
128
- """Pick an embedding + reranker combo; returns (embedding, reranker, notes)."""
151
+ def ram_limited(specs: Specs) -> bool:
152
+ """True when RAM is known and below the balanced/best comfort threshold."""
153
+ return 0 < specs.ram_gb < LOW_RAM_GB
154
+
155
+
156
+ def recommend(
157
+ quality: str, specs: Specs, *, enforce_ram: bool = True
158
+ ) -> tuple[EmbeddingChoice, RerankerChoice, list[str]]:
159
+ """Pick an embedding + reranker combo; returns (embedding, reranker, notes).
160
+
161
+ enforce_ram=False (interactive callers whose user already confirmed the pick)
162
+ keeps the requested tier on low-RAM machines and warns instead of downgrading.
163
+ """
129
164
  if quality not in EMBEDDINGS:
130
165
  raise RagxError(f"quality must be one of {'/'.join(QUALITY_TIERS)}, got {quality!r}")
131
166
  notes: list[str] = []
132
167
  tier = quality
133
- if 0 < specs.ram_gb < 8 and tier in ("balanced", "best"):
134
- notes.append(f"{specs.ram_gb:.0f} GB RAM detected — dropping to the fast embedding tier")
135
- tier = "fast"
168
+ if ram_limited(specs) and tier in ("balanced", "best"):
169
+ if enforce_ram:
170
+ notes.append(
171
+ f"{specs.ram_gb:.0f} GB RAM detected — dropping to the fast embedding tier"
172
+ )
173
+ tier = "fast"
174
+ else:
175
+ notes.append(
176
+ f"{specs.ram_gb:.0f} GB RAM detected — the {tier} tier may be slow on this machine"
177
+ )
136
178
  if tier == "jina-nano":
137
179
  notes.append("jina-nano is CC-BY-NC licensed — non-commercial use only")
138
180
  return EMBEDDINGS[tier], RERANKER, notes
@@ -42,6 +42,15 @@ def test_recommend_downgrades_on_low_ram():
42
42
  assert emb.tier == "best"
43
43
 
44
44
 
45
+ def test_recommend_no_enforce_warns_instead_of_downgrading():
46
+ emb, _, notes = recommend("best", Specs(ram_gb=4.0, macos=False), enforce_ram=False)
47
+ assert emb.tier == "best"
48
+ assert any("may be slow" in n for n in notes)
49
+ # plenty of RAM: no warning either way
50
+ _, _, notes = recommend("best", BIG, enforce_ram=False)
51
+ assert notes == []
52
+
53
+
45
54
  def test_recommend_rejects_unknown_quality():
46
55
  with pytest.raises(RagxError):
47
56
  recommend("ludicrous", BIG)
@@ -301,6 +310,120 @@ def test_models_rejects_unknown_engine(corpus):
301
310
  assert result.exit_code == 2
302
311
 
303
312
 
313
+ def test_models_jina_auto_locks_embed_engine_when_unset(corpus, monkeypatch, tmp_path):
314
+ """quality=jina-nano without --embed-engine no longer errors: the engine is
315
+ locked to llama-server (the only one that can serve it)."""
316
+ gguf = lmstudio.InstalledModel(
317
+ model_key="v5-nano-retrieval", type="embedding", format="gguf",
318
+ path="jinaai/jina-embeddings-v5-text-nano-retrieval-GGUF/v5-nano-retrieval-Q8_0.gguf",
319
+ size_bytes=1,
320
+ )
321
+ monkeypatch.setattr(lmstudio, "find_lms", lambda: "/fake/lms")
322
+ monkeypatch.setattr(lmstudio, "find_installed", lambda lms, fragment: gguf)
323
+ monkeypatch.setattr(lmstudio, "models_root", lambda: tmp_path / "lmsmodels")
324
+ result = runner.invoke(
325
+ app, ["models", "--quality", "jina-nano", "--rerank-engine", "llama-server",
326
+ "--yes", "--json"],
327
+ )
328
+ assert result.exit_code == 0, result.output
329
+ doc = json.loads(result.stdout)
330
+ assert doc["embedding"]["engine"] == "llama-server"
331
+ assert any("locked" in n for n in doc["notes"])
332
+ cfg = Config.load(corpus)
333
+ assert cfg.get("embeddings.provider") == "llama-server"
334
+
335
+
336
+ # --- interactive flow ---------------------------------------------------------
337
+
338
+
339
+ def _script_prompts(monkeypatch, answers, confirms):
340
+ """Feed scripted answers into the numbered pickers and confirms; a TTY stdin
341
+ makes run_flow take the interactive branch (prompts never touch real stdin)."""
342
+ from types import SimpleNamespace
343
+
344
+ monkeypatch.setattr("sys.stdin", SimpleNamespace(isatty=lambda: True))
345
+ answer_iter, confirm_iter = iter(answers), iter(confirms)
346
+ monkeypatch.setattr("typer.prompt", lambda *a, **k: next(answer_iter))
347
+ monkeypatch.setattr("typer.confirm", lambda *a, **k: next(confirm_iter))
348
+
349
+
350
+ def test_pick_numbered_reprompts_until_valid(monkeypatch):
351
+ from ragx.cli import models_ui
352
+
353
+ answers = iter(["9", "abc", "2"])
354
+ monkeypatch.setattr("typer.prompt", lambda *a, **k: next(answers))
355
+ assert models_ui.pick_numbered(["a", "b", "c"], default=1) == 2
356
+
357
+
358
+ def test_pick_tier_low_ram_defaults_and_override(monkeypatch):
359
+ from ragx.cli import models_ui
360
+
361
+ low = Specs(ram_gb=4.0, macos=False)
362
+ # decline the RAM override for "best", then settle on "fast"
363
+ _script_prompts(monkeypatch, answers=["3", "1"], confirms=[False])
364
+ assert models_ui.pick_tier(low) == "fast"
365
+ # confirm the override: the explicit choice is honored
366
+ _script_prompts(monkeypatch, answers=["3"], confirms=[True])
367
+ assert models_ui.pick_tier(low) == "best"
368
+
369
+
370
+ def test_models_interactive_flow_plans_then_writes(corpus, monkeypatch, capsys):
371
+ from ragx.cli.models_cmd import run_flow
372
+ from ragx.core.catalog import Specs as SpecsCls
373
+
374
+ installed = lmstudio.InstalledModel(
375
+ model_key="text-embedding-bge-m3", type="embedding", format="gguf",
376
+ path="gaianet/bge-m3-GGUF/x.gguf", size_bytes=1,
377
+ )
378
+ monkeypatch.setattr("ragx.cli.models_cmd.detect_specs", lambda: SpecsCls(32.0, True))
379
+ monkeypatch.setattr("shutil.which", lambda name: None)
380
+ monkeypatch.setattr(lmstudio, "find_lms", lambda: "/fake/lms")
381
+ monkeypatch.setattr(lmstudio, "find_installed", lambda lms, fragment: installed)
382
+ monkeypatch.setattr(
383
+ lmstudio, "download",
384
+ lambda lms, ref: pytest.fail("must not download an installed model"),
385
+ )
386
+ # tier 2 (balanced), embed engine 2 (lm-studio), rerank engine 2 (sentence-transformers)
387
+ _script_prompts(monkeypatch, answers=["2", "2", "2"], confirms=[True])
388
+
389
+ run_flow(corpus)
390
+
391
+ err = capsys.readouterr().err
392
+ assert "machine:" in err
393
+ assert "── embedding model" in err
394
+ assert "── plan" in err
395
+ assert "already downloaded ✓" in err
396
+ cfg = Config.load(corpus)
397
+ assert cfg.get("embeddings.model") == "text-embedding-bge-m3"
398
+ assert cfg.get("embeddings.provider") == "openai"
399
+ assert cfg.get("rerank.provider") == "sentence-transformers"
400
+
401
+
402
+ def test_models_interactive_jina_skips_embed_engine_prompt(corpus, monkeypatch, tmp_path):
403
+ from ragx.cli.models_cmd import run_flow
404
+ from ragx.core.catalog import Specs as SpecsCls
405
+
406
+ gguf = lmstudio.InstalledModel(
407
+ model_key="v5-nano-retrieval", type="embedding", format="gguf",
408
+ path="jinaai/jina-embeddings-v5-text-nano-retrieval-GGUF/v5-nano-retrieval-Q8_0.gguf",
409
+ size_bytes=1,
410
+ )
411
+ monkeypatch.setattr("ragx.cli.models_cmd.detect_specs", lambda: SpecsCls(32.0, True))
412
+ monkeypatch.setattr("shutil.which", lambda name: None)
413
+ monkeypatch.setattr(lmstudio, "find_lms", lambda: "/fake/lms")
414
+ monkeypatch.setattr(lmstudio, "find_installed", lambda lms, fragment: gguf)
415
+ monkeypatch.setattr(lmstudio, "models_root", lambda: tmp_path / "lmsmodels")
416
+ # exactly ONE prompt expected (rerank engine) — an embed-engine prompt would
417
+ # exhaust the iterator and fail the test
418
+ _script_prompts(monkeypatch, answers=["1"], confirms=[True])
419
+
420
+ run_flow(corpus, quality="jina-nano")
421
+
422
+ cfg = Config.load(corpus)
423
+ assert cfg.get("embeddings.provider") == "llama-server"
424
+ assert cfg.get("rerank.provider") == "llama-server"
425
+
426
+
304
427
  def test_init_routes_into_models_flow(tmp_path, monkeypatch):
305
428
  called = {}
306
429
  monkeypatch.setattr(
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes