Configuration
-
Single Source of Truth
server/models/tribrid_config_model.pydefines every tunable parameter with PydanticField()constraints. -
Generated Types
uv run scripts/generate_types.pyproducesweb/src/types/generated.ts. No hand-written interfaces. -
Constraints Enforced
Min/max ranges, enums, and defaults are validated at load time with precise error messages.
Get started Configuration Config reference API
Workflow: Pydantic First
1) Add/modify fields in Pydantic. 2) Regenerate TS types. 3) Wire stores/hooks/components using generated types. 4) Update backend logic to honor new fields.
Corpus ID Migration
Prefer corpus_id. Models accept repo_id via AliasChoices for backward compatibility, but serialize corpus_id.
No Adapters
If the frontend needs a different shape, change the Pydantic model and regenerate. Adapters introduce drift and are not allowed.
Credentials are redacted on the wire
GET /api/config serves the password inside indexing.postgres_url and the authorization value in tracing.otlp_headers as [redacted]; a PUT/PATCH that returns the marker restores the stored value, so "leave it unchanged" round-trips safely. Typing a real value rotates the secret, and a marker with nothing stored behind it is a 422. Run-record config snapshots (eval, reranker, agent, synthetic) are redacted the same way. See Security.
Cloud embeddings are validated against the gateway route at write time
Saving a config where embedding.embedding_backend=provider and embedding.embedding_type=openai is checked against the catalog's native embedding routes (server/api/config.py _validate_model_capabilities): the selected embedding.embedding_model must be a gateway-served OpenAI embedding row (text-embedding-3-small or text-embedding-3-large), and embedding.embedding_dim must not exceed that model's full capacity (1536 / 3072 — catalog dimensions are model capacity, not your shortened output size). A write that fails either check answers 422 and persists nothing, global or corpus-scoped. The check applies even with indexing.skip_dense set, because retrieval and the semantic cache can still need embeddings; deterministic and non-OpenAI embedding backends are unaffected.
Derivation Chain
flowchart TB
P["Pydantic\ntribrid_config_model.py"] --> G["pydantic2ts\n(generate_types.py)"]
G --> T["generated.ts"]
T --> S["Zustand Stores"]
S --> H["React Hooks"]
H --> C["Components"]
P --> A["FastAPI Schemas"]
A --> UI["API Responses"] Major Sections (Selected Fields)
Need the full 1000+ parameter surface?
Use the auto-generated Configuration Reference pages. They enumerate every tunable key with: - JSON path (retrieval.rrf_k_div) - env-style key (when available, via TriBridConfig.to_flat_dict()) - type, default, and validation constraints - long-form “tooltip” guidance pulled from data/glossary.json (when present)
| Section | Key Fields (examples) | Why it matters |
|---|---|---|
| retrieval | final_k, eval_final_k, fallback_confidence, conf_top1, conf_avg5, conf_any, multi_query_m | Retry/accept gates, result shaping, and the eval-only final-k |
| fusion | method, vector_weight, sparse_weight, graph_weight, rrf_k, normalize_scores | How legs combine into a single ranking |
| vector_search | enabled, top_k, similarity_threshold | Dense (Qdrant) candidate size |
| sparse_search | enabled, top_k, bm25_k1, bm25_b | Sparse (Qdrant BM25) candidate size and scoring |
| graph_search | enabled, max_hops, top_k, chunk_neighbor_window, include_communities | Qdrant-seeded Neo4j traversal behavior |
| embedding | embedding_type, embedding_model, embedding_dim, embedding_batch_size | Embedding provider + dimensions |
| chunking | chunking_strategy, chunk_size, chunk_overlap, max_chunk_tokens, preserve_imports | Index quality and performance |
| reranking | reranker_mode, reranker_*, tribrid_reranker_* | Cloud/learning reranker stage tuning |
| graph_storage | neo4j_*, neo4j_database_mode | Graph connectivity and isolation |
| chat.recall_gate | enabled, default_intensity, skip_*, *top_k, *recency_weight | Smart memory gating |
| chat.web | enabled, engine, max_results, max_total_results, max_characters | Server-owned web-search policy for Chat |
| indexing | figures.*, estimate.* | Vision-described figures inside Docling-converted PDFs (off by default; the run refuses to start if the vision alias is not vision-capable), plus the measured-estimate floors estimate.max_relative_error and estimate.min_files_per_format |
| document_viewer | page_render_scale, thumbnail_render_scale, max_text_bytes | Source document evidence viewer limits |
Removed: the dead retrieval.topk_* and weight duplicates
retrieval.rrf_k_div, retrieval.langgraph_final_k, retrieval.bm25_weight, retrieval.vector_weight, retrieval.topk_dense, and retrieval.topk_sparse are gone from the config model. They duplicated the knobs the pipeline actually reads (server/retrieval/fusion.py): fusion weights live under fusion.*, the dense candidate size is vector_search.top_k, and the sparse candidate size is sparse_search.top_k. A saved config that still carries the old keys simply ignores them — retune at the canonical homes. retrieval.eval_final_k stays: it is the evaluation-only final-k (server/api/eval.py) and is deliberately distinct from retrieval.final_k, not a duplicate.
Removed: the legacy base+suffix chat prompt composition
chat.system_prompt_base, chat.system_prompt_rag_suffix, and chat.system_prompt_recall_suffix are gone from ChatConfig. Exactly one of the four state prompts (system_prompt_direct, system_prompt_rag, system_prompt_recall, system_prompt_rag_and_recall) is selected per message by whether RAG and/or Recall context is present, so the legacy base+suffix path was a second, conflicting instruction surface behind the live one. GET /api/prompts lists only the four state prompts, and a persisted config that still carries the removed keys loads cleanly — the config-store upgrade path strips them (server/services/config_store.py), as does the flat loader (server/config.py). If you customized a suffix, move that text into the state prompt whose behavior you want it to affect.
Removed: graph mode and Neo4j chunk-vector knobs
graph_search.mode, graph_search.chunk_seed_overfetch_multiplier, graph_search.chunk_entity_expansion_enabled, graph_search.chunk_entity_expansion_weight, graph_indexing.store_chunk_embeddings, graph_indexing.chunk_vector_index_name, graph_indexing.chunk_embedding_property, graph_indexing.vector_similarity_function, graph_indexing.wait_vector_index_online, graph_indexing.vector_index_online_timeout_s, and graph_storage.community_algorithm are gone from the config model. The graph leg is now one pipeline — dense Qdrant seeds joined to generation-scoped Neo4j entities through FROM_CHUNK, expanded through relationships, NEXT_CHUNK neighbors, and GDS Leiden communities — so the chunk/entity toggle and its blend knobs have nothing left to control. A saved config that still carries the old keys loads cleanly; the config-store upgrade path strips them (server/services/config_store.py).
Fusion Configuration
| Field | Type | Constraints | Description |
|---|---|---|---|
fusion.method | Literal["rrf","weighted"] | required | Fusion algorithm |
fusion.vector_weight | float | 0.0–1.0 | Weight for vector scores (weighted mode) |
fusion.sparse_weight | float | 0.0–1.0 | Weight for sparse scores (weighted mode) |
fusion.graph_weight | float | 0.0–1.0 | Weight for graph scores (weighted mode) |
fusion.rrf_k | int | 1–200 | RRF smoothing constant |
fusion.normalize_scores | bool | — | Normalize inputs before weighted fusion |
Weights Must Sum
Weighted mode normalizes tri-brid weights to approximately 1.0. If total ≤ 0, safe defaults are applied.
Graph Retrieval Configuration
| Field | Type | Constraints | Description |
|---|---|---|---|
graph_search.enabled | bool | — | Enable the graph leg (Qdrant-seeded Neo4j traversal) in retrieval |
graph_search.max_hops | int | 1–5 | Traversal depth from seed entities |
graph_search.top_k | int | 5–100 | Qdrant seed Top-K for the graph leg |
graph_search.chunk_neighbor_window | int | 0–10 | Include up to N adjacent NEXT_CHUNK chunks around relationship hits |
graph_search.include_communities | bool | — | Include community expansion (GDS Leiden communities written at index time) |
graph_search.max_related_entities_per_seed | int | 1–1000 | Cap on related entities each seed entity may expand to (nearest first, then most connected) — bounds how far a hub entity can reach |
Retrieval and Confidence Gates
| Field | Default | Description |
|---|---|---|
retrieval.final_k | 10 | Final top-k after fusion/rerank |
retrieval.eval_final_k | 5 | Final-k used only by the evaluation flow (server/api/eval.py); a distinct knob from retrieval.final_k |
vector_search.top_k | 50 | Dense (Qdrant) candidates per query |
sparse_search.top_k | 50 | Sparse (Qdrant BM25) candidates per query |
retrieval.conf_top1 | 0.62 | Early accept top-1 threshold |
retrieval.conf_avg5 | 0.55 | Group quality threshold (top-5) |
retrieval.conf_any | 0.55 | Safety net minimum |
Chat Recall Gate (Memory)
| Field | Default | Meaning |
|---|---|---|
chat.recall_gate.enabled | true | Turn smart Recall gating on/off |
chat.recall_gate.default_intensity | standard | Fallback when no strong signal |
chat.recall_gate.skip_greetings | true | Skip trivial conversational glue |
chat.recall_gate.light_top_k | 3 | Light mode snippets |
chat.recall_gate.standard_top_k | 5 | Standard mode snippets |
chat.recall_gate.deep_top_k | 10 | Deep mode snippets |
chat.recall_gate.standard_recency_weight | 0.3 | Recent > old balance |
chat.recall_gate.deep_recency_weight | 0.5 | Stronger recency in deep mode |
Read and Update Config via API (Annotated)
import httpx
base = "http://127.0.0.1:8012/api"
# Read full config (1)!
cfg = httpx.get(f"{base}/config").json()
# Patch a section (fusion) (2)!
patch = {"method": "weighted", "vector_weight": 0.5, "sparse_weight": 0.3, "graph_weight": 0.2}
httpx.patch(f"{base}/config/fusion", json=patch).raise_for_status()
# Reset to defaults (3)!
httpx.post(f"{base}/config/reset").raise_for_status()
- Fetch authoritative nested config
- Sectional PATCH is validated by Pydantic
- Reset restores model defaults
BASE=http://127.0.0.1:8012/api
# Read (1)!
curl -sS "$BASE/config" | jq .
# Patch fusion (2)!
curl -sS -X PATCH "$BASE/config/fusion" \
-H 'Content-Type: application/json' \
-d '{"method":"weighted","vector_weight":0.5,"sparse_weight":0.3,"graph_weight":0.2}' | jq .
# Reset (3)!
curl -sS -X POST "$BASE/config/reset" | jq .
- Retrieve full config
- Update only the
fusionsection - Restore defaults (useful during experiments)
import type { TriBridConfig } from "./web/src/types/generated";
async function loadConfig(): Promise<TriBridConfig> {
const r = await fetch("/api/config");
return await r.json(); // (1)!
}
async function patchFusion() {
await fetch("/api/config/fusion", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ method: "weighted", vector_weight: 0.5, sparse_weight: 0.3, graph_weight: 0.2 }),
}); // (2)!
}
- Typed fetch of config
- Typed partial update for fusion settings
Safe Defaults and Tradeoffs
- Vector vs Sparse vs Graph weights
- If unsure, do this:
fusion.method = "rrf",fusion.rrf_k = 60. RRF is robust across modalities. - Candidate sizes
- Start with
vector_search.top_k=50,sparse_search.top_k=50,graph_search.top_k=30. Increase for recall-heavy workloads. (retrieval.topk_denseandretrieval.topk_sparseno longer exist.) - Confidence gates
- Start with
conf_top1=0.62,conf_avg5=0.55. Raise to increase precision (fewer answers), lower for more answers.
Where values come from
All defaults live in Pydantic Field(default=...) initializers. UI sliders and inputs read min/max from the same model. The server enforces the same constraints.
UI numeric controls are clamped to the same model
Every numeric config control in the UI is a clamped NumberField whose advertised min/max match the Pydantic field it writes — the UI cannot accept a value the PATCH /api/config/{section} would reject, and a bound the model does not have is caught by a test against the model itself (tests/unit/test_clean_start_defaults.py) rather than surfacing later as an unattributed 422. If you tighten a ge/le in Pydantic, regenerate the TypeScript types and the UI clamp follows automatically.
Integration readiness (GET /api/config/readiness)
Admin → Dependencies renders GET /api/config/readiness: one IntegrationReadiness row per registered integration contract (server/config_control_plane.py), each with a state, the config paths and secrets it requires, failing checks, and — when it is not ready — the surfaces it currently blocks. blocked_surfaces is what an operator reads as "what stops working right now", so two contracts were tightened to say exactly that and no more:
- MLflow blocks
trainingonly. MLflow is the training lane's run/artifact truth (Learning Reranker and Learning Agent runs). The eval lane scores with Ragas and Promptfoo and imports nothing from MLflow, so an unready MLflow server no longer claims to block eval. A source-scan test (tests/unit/test_config_control_plane.py) keeps that claim true: if the eval lane ever starts using MLflow, the contract must change with it. - vLLM blocks chat and benchmark only when they actually route through it. Chat and the benchmark answer through whichever generation lane is selected. With the LiteLLM gateway enabled and a cloud chat model, an off or unreachable vLLM blocks only its own serving lane (
runtime). With the gateway disabled — or with the chat default model pinned to the local serving alias (ragweld-local, which is itself a gateway route onto the vLLM lane) — chat and the benchmark fall back to the serving lane, and the readiness row lists them as blocked.
Concept diagram (the vLLM blocked_surfaces decision only — the full service map is on the generated runtime-topology page):
flowchart TB
VR["vLLM readiness probe\n(chat.vllm.enabled + base_url)"]
VR --> UP{"vLLM serving lane up?"}
UP -->|"yes"| READY["state = ready\nnothing blocked"]
UP -->|"no"| GW{"chat.litellm.enabled and\nchat default model is not\nthe local serving alias?"}
GW -->|"yes"| ONLY["blocked_surfaces = [runtime]\nchat + benchmark answer through the gateway"]
GW -->|"no"| CHAT["blocked_surfaces = [runtime, chat, benchmark]\nchat + benchmark fall back to the serving lane"] If you're not sure what a blocked surface means
Read the row's operator_hint first — it names the fix (enable the lane, set the missing config path, or bring the dependency up). A surface that is not listed is not affected by that integration's state; that is the whole point of the narrowed contracts.
How numeric fields behave in the UI
Every numeric config control in the workbench is one component: web/src/components/ui/NumberField.tsx. It exists so the question "what happens when I type a value past the bound?" has exactly one answer on every surface — Chat settings, the RAG subtabs, both training studios, Eval run settings, Data Quality, Grafana config. And like every config edit in the workbench, its commit is staged: the edit lands in the working config and nothing reaches the server until the footer's Apply button writes the whole document.
Concept diagram (the staged commit mechanism only — the read/patch config API itself is documented above):
flowchart LR
subgraph s_field["Config controls (web/src/components/ui/NumberField.tsx)"]
EDIT["Edit: blur / Tab / Enter\\nor a strategy-card click"] --> CLAMP["NumberField clamps to\\nPydantic ge/le at commit"]
CLAMP --> STAGE["stageSection / stageSectionReplace\\n(web/src/stores/useConfigStore.ts)"]
end
STAGE --> DIFF["changedConfigPaths\\n(web/src/utils/configDiff.ts)"]
DIFF --> FOOTER["Apply button:\\n'Apply N changes' + 'Saved' ack"]
DIFF --> WARN{"Index-invalidating\\nsection staged?\\n(chunking / embedding / tokenization)"}
WARN -->|"yes"| CONFIRM["Confirmation dialog names\\nthe sections and the re-index"]
WARN -->|"no"| APPLY["Apply: PUT /api/config\\none whole-config write"]
CONFIRM --> APPLY
APPLY --> SERVER["Whole config re-validated atomically\\n(server/api/config.py update_config)"]
SERVER -->|"200"| PERSISTED["persisted = saved config\\n'Saved' acknowledgement"]
SERVER -->|"422"| FIELDS["formatSaveError -> fieldErrors[dotted path]\\nrendered under the field, role=alert"]
SERVER -->|"409"| CONFLICT["Index-contract conflict message\\n+ 'Reload latest' button"] In practice:
- Typing is never saved. The box holds raw text while you edit; blurring, Tabbing, or pressing Enter stages the edit — it writes nothing.
- The clamp happens at staging. A value past the advertised min/max is corrected before it is staged, so the raw value never reaches the server — the Apply PUT carries the clamped value, and a fresh
GET /api/configconfirms the persisted value is the clamped one. - Apply is the only write. The footer counts the staged leaf changes (
Apply 3 changes) and shows a briefSavedacknowledgement after a successful write. Loading a corpus or switching corpora replaces both snapshots, so unapplied staged edits are dropped by design — apply before you leave a surface. - A rejected PUT attributes to fields. The whole config is validated atomically, so a
422means nothing was saved.web/src/utils/saveErrorMessage.tsshapes the server's detail into per-field messages (never axios's raw status string), and aNumberFieldwith aconfigPathprop renders its own message under the box as arole=alert. - A 409 conflict offers a way out. When the server refuses the write because it would invalidate the stored index contract, the footer shows the reason plus a Reload latest button that discards the staged edits and re-reads the server's current config.
- Index-invalidating changes warn before they write. Staged edits under
chunking,embedding, ortokenizationmean the stored index no longer matches the config; Apply shows a confirmation naming those sections first. No side door commits them silently either:flushPendingPatches(used before Index Now and the Infrastructure → Paths save) throws until you Apply or discard. - Fields that are not config values still clamp. The Storage Calculator's inputs and ad-hoc request parameters (Synthetic Lab, the graph max-hops control) pass bounds but no
configPath— they clamp, there is just nothing persisted to attribute a server error to.
One deliberate exception: Chat's Top-K override
The Chat Top-K (results) control is a nullable per-conversation override: clearing it and blurring reverts to the corpus's configured retrieval.final_k, and it is never persisted to config. NumberField's commit treats an empty box as "restore the last committed value", so it cannot express "the operator cleared this" — adopting it there would silently remove the only way back to the corpus default. The control still clamps (1–100) inline and is pinned by the guard test below.
Enforcement is a test against the model itself, not a review guideline (tests/unit/test_clean_start_defaults.py):
test_every_number_field_advertises_its_pydantic_boundswalks everyNumberFieldinweb/src, resolves its config path (an explicitconfigPath="a.b.c"prop, or auseConfigField<number>binding), and asserts the advertised min/max equal the Pydanticge/le— it checks 100+ controls, and aNumberFieldwith neither marker must be a genuine non-config input.test_no_config_editor_still_writes_a_raw_number_inputscans every frontend source and forbids a raw<input type="number">outsideNumberField.tsx, with one pinned, documented exception (the Chat Top-K override above).
The end-to-end behavior is proven against a live stack in web/tests/e2e/exhaustive/numberfield_migration.spec.ts — see Testing.
If you're not sure
Add numeric controls as <NumberField configPath="section.field" ... /> with min/max matching the Pydantic field. A bound the model does not have fails the bounds test above at build time, instead of surfacing later as an unattributed 422 in production. If a value must be clearable/nullable, do not use NumberField — clamp inline and document why, like the Chat Top-K control does.