Studio: app-level RAG defaults for new KBs

Power users can now set their preferred chunking strategy / mode /
embedder once in Settings → Knowledge Bases and have new KBs use those
values by default, instead of toggling on every create.

Backend
- routes/rag.py:
  - GET /api/rag/defaults returns the stored RagDefaults (or sensible
    fallbacks when nothing is set: standard / text / null embedder).
  - PUT /api/rag/defaults is PATCH-style — only fields present in the
    body overwrite. The (multimodal, late) constraint is enforced
    here too, so users can't poison the defaults with a combination
    the create path would reject.
- Persistence reuses the existing chat_settings store via
  upsert_chat_settings_merge; the values live under a single
  rag.defaults key as a nested JSON dict.

Frontend
- rag-api.ts: getRagDefaults / setRagDefaults wrappers + RagDefaults
  + UpdateRagDefaultsRequest types.
- rag-store.ts: defaults state, loadDefaults / updateDefaults
  actions. loadDefaults swallows errors so a missing endpoint just
  leaves defaults null.
- rag-defaults-section.tsx (new): self-contained mode + strategy +
  embedding-model controls, persists on change. Used in the Settings
  KB tab below the ThreadIndexList section.
- knowledge-bases-tab.tsx: mounts RagDefaultsSection below thread
  indexes with a separator.
- kb-create-dialog.tsx: loads defaults on open and prefills the form
  with them (falls back to hard-coded standard / text when defaults
  haven't loaded yet). reset() returns to the latest defaults rather
  than the hard-coded ones.
This commit is contained in:
Roland Tannous 2026-05-24 12:47:16 +04:00
commit ca83bea538
6 changed files with 314 additions and 8 deletions

View file

@ -43,7 +43,11 @@ async def _sse_auth(
from core.rag import embeddings, ingestion, reranker, retrieval, vector_store
from core.rag.vector_store import kb_scope, thread_scope
from loggers import get_logger
from storage.studio_db import get_connection
from storage.studio_db import (
get_connection,
list_chat_settings,
upsert_chat_settings_merge,
)
from utils.paths.storage_roots import ensure_dir, rag_uploads_root
from utils.rag.config import (
RAG_MAX_UPLOAD_MB,
@ -407,6 +411,75 @@ def list_knowledge_bases(
return KBListResponse(knowledge_bases = [_row_to_kb(r) for r in rows])
class RagDefaults(BaseModel):
chunking_strategy: ChunkingStrategy = "standard"
mode: KBMode = "text"
embedding_model: str | None = None
class UpdateRagDefaultsRequest(BaseModel):
"""Patch shape — only fields present overwrite stored values."""
chunking_strategy: ChunkingStrategy | None = None
mode: KBMode | None = None
embedding_model: str | None = None
_DEFAULTS_KEY = "rag.defaults"
def _load_rag_defaults() -> RagDefaults:
settings = list_chat_settings()
raw = settings.get(_DEFAULTS_KEY) or {}
if not isinstance(raw, dict):
raw = {}
return RagDefaults(
chunking_strategy = raw.get("chunking_strategy") or "standard",
mode = raw.get("mode") or "text",
embedding_model = raw.get("embedding_model"),
)
@router.get("/defaults", response_model = RagDefaults)
def get_rag_defaults(
current_subject: str = Depends(get_current_subject),
) -> RagDefaults:
return _load_rag_defaults()
@router.put("/defaults", response_model = RagDefaults)
def set_rag_defaults(
payload: UpdateRagDefaultsRequest,
current_subject: str = Depends(get_current_subject),
) -> RagDefaults:
current = _load_rag_defaults()
new_strategy = payload.chunking_strategy or current.chunking_strategy
new_mode = payload.mode or current.mode
# PATCH-style — passing an empty string clears the override; a
# null/missing field keeps the current value.
if payload.embedding_model is None:
new_embedder = current.embedding_model
elif payload.embedding_model.strip() == "":
new_embedder = None
else:
new_embedder = payload.embedding_model.strip()
_validate_mode_combo(new_mode, new_strategy)
upsert_chat_settings_merge(
{
_DEFAULTS_KEY: {
"chunking_strategy": new_strategy,
"mode": new_mode,
"embedding_model": new_embedder,
}
}
)
return RagDefaults(
chunking_strategy = new_strategy,
mode = new_mode,
embedding_model = new_embedder,
)
class ReingestKBRequest(BaseModel):
"""All fields optional — omitting one keeps the KB's current value."""
chunking_strategy: ChunkingStrategy | None = None

View file

@ -246,6 +246,34 @@ export async function reingestThreadDocuments(
return parseJsonOrThrow<ReingestResponse>(response);
}
export interface RagDefaults {
chunking_strategy: ChunkingStrategy;
mode: KBMode;
embedding_model: string | null;
}
export async function getRagDefaults(): Promise<RagDefaults> {
const response = await authFetch("/api/rag/defaults");
return parseJsonOrThrow<RagDefaults>(response);
}
export interface UpdateRagDefaultsRequest {
chunking_strategy?: ChunkingStrategy;
mode?: KBMode;
embedding_model?: string | null;
}
export async function setRagDefaults(
payload: UpdateRagDefaultsRequest,
): Promise<RagDefaults> {
const response = await authFetch("/api/rag/defaults", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
return parseJsonOrThrow<RagDefaults>(response);
}
// ------------------------------------------------------------------
// Search
// ------------------------------------------------------------------

View file

@ -19,13 +19,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useState } from "react";
import { useEffect, useState } from "react";
import type {
ChunkingStrategy,
KBMode,
KnowledgeBase,
} from "../api/rag-api";
import { useKnowledgeBases } from "../hooks/use-knowledge-bases";
import { useRagStore } from "../stores/rag-store";
export function KBCreateDialog({
open,
@ -37,21 +38,48 @@ export function KBCreateDialog({
onCreated?: (kb: KnowledgeBase) => void;
}) {
const { createKB } = useKnowledgeBases();
const defaults = useRagStore((s) => s.defaults);
const loadDefaults = useRagStore((s) => s.loadDefaults);
// Cache the initial values so reset() puts us back to the latest
// saved defaults rather than the hard-coded ones.
const initialStrategy: ChunkingStrategy =
defaults?.chunking_strategy ?? "standard";
const initialMode: KBMode = defaults?.mode ?? "text";
const initialEmbedder = defaults?.embedding_model ?? "";
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [embeddingModel, setEmbeddingModel] = useState("");
const [embeddingModel, setEmbeddingModel] = useState(initialEmbedder);
const [chunkingStrategy, setChunkingStrategy] =
useState<ChunkingStrategy>("standard");
const [mode, setMode] = useState<KBMode>("text");
useState<ChunkingStrategy>(initialStrategy);
const [mode, setMode] = useState<KBMode>(initialMode);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch defaults on first open and re-sync the form when they arrive.
useEffect(() => {
if (open && !defaults) {
void loadDefaults();
}
}, [open, defaults, loadDefaults]);
useEffect(() => {
if (open && defaults) {
setChunkingStrategy(defaults.chunking_strategy);
setMode(defaults.mode);
setEmbeddingModel(defaults.embedding_model ?? "");
}
// Intentional: only when `open` flips, not on every defaults change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const reset = () => {
setName("");
setDescription("");
setEmbeddingModel("");
setChunkingStrategy("standard");
setMode("text");
setEmbeddingModel(defaults?.embedding_model ?? "");
setChunkingStrategy(defaults?.chunking_strategy ?? "standard");
setMode(defaults?.mode ?? "text");
setError(null);
setSubmitting(false);
};

View file

@ -0,0 +1,148 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useEffect, useState } from "react";
import type { ChunkingStrategy, KBMode } from "../api/rag-api";
import { useRagStore } from "../stores/rag-store";
/**
* Defaults for newly-created KBs, surfaced in the Settings Knowledge
* Bases tab. Values pre-fill the KB create dialog so power users can
* pick their preferred strategy/mode once instead of toggling per KB.
*
* Backed by chat_settings via PUT /api/rag/defaults. The selects
* enforce the same (multimodal, late) constraint as the create
* dialog.
*/
export function RagDefaultsSection() {
const defaults = useRagStore((s) => s.defaults);
const loadDefaults = useRagStore((s) => s.loadDefaults);
const updateDefaults = useRagStore((s) => s.updateDefaults);
const [chunkingStrategy, setChunkingStrategy] =
useState<ChunkingStrategy>("standard");
const [mode, setMode] = useState<KBMode>("text");
const [embeddingModel, setEmbeddingModel] = useState("");
const [error, setError] = useState<string | null>(null);
// Load defaults on mount; reflect them in local state.
useEffect(() => {
void loadDefaults();
}, [loadDefaults]);
useEffect(() => {
if (defaults) {
setChunkingStrategy(defaults.chunking_strategy);
setMode(defaults.mode);
setEmbeddingModel(defaults.embedding_model ?? "");
}
}, [defaults]);
const lateDisabled = mode === "multimodal";
const multimodalDisabled = chunkingStrategy === "late";
const persist = (patch: {
chunking_strategy?: ChunkingStrategy;
mode?: KBMode;
embedding_model?: string | null;
}) => {
setError(null);
void updateDefaults(patch).catch((err) => {
setError(err instanceof Error ? err.message : String(err));
});
};
return (
<div className="flex flex-col gap-3">
<div>
<h3 className="text-sm font-medium">Defaults for new knowledge bases</h3>
<p className="text-xs text-muted-foreground">
Pre-fills the KB create dialog. Existing KBs keep their own
settings use the Reconfigure button to change those.
</p>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="defaults-mode">Mode</Label>
<Select
value={mode}
onValueChange={(v) => {
const next = v as KBMode;
setMode(next);
persist({ mode: next });
}}
>
<SelectTrigger id="defaults-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">Text only</SelectItem>
<SelectItem
value="multimodal"
disabled={multimodalDisabled}
title={
multimodalDisabled
? "Multimodal cannot be combined with late chunking"
: undefined
}
>
Multimodal
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="defaults-strategy">Chunking strategy</Label>
<Select
value={chunkingStrategy}
onValueChange={(v) => {
const next = v as ChunkingStrategy;
setChunkingStrategy(next);
persist({ chunking_strategy: next });
}}
>
<SelectTrigger id="defaults-strategy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">Standard</SelectItem>
<SelectItem
value="late"
disabled={lateDisabled}
title={
lateDisabled
? "Late chunking cannot be combined with multimodal mode"
: undefined
}
>
Late chunking
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="defaults-model">
Default embedding model override (optional)
</Label>
<Input
id="defaults-model"
value={embeddingModel}
onChange={(e) => setEmbeddingModel(e.target.value)}
onBlur={() => persist({ embedding_model: embeddingModel })}
placeholder="Leave blank to use the matrix default"
/>
</div>
{error ? <div className="text-xs text-destructive">{error}</div> : null}
</div>
);
}

View file

@ -8,18 +8,22 @@ import {
type CreateKnowledgeBaseRequest,
deleteDocument as apiDeleteDocument,
deleteKnowledgeBase as apiDeleteKB,
getRagDefaults as apiGetRagDefaults,
type JobEvent,
type KnowledgeBase,
listKBDocuments,
listKnowledgeBases,
listThreadDocuments,
listThreadIndexes,
type RagDefaults,
type RagDocument,
type ReingestKBOptions,
reingestKnowledgeBase as apiReingestKB,
reingestThreadDocuments as apiReingestThread,
setRagDefaults as apiSetRagDefaults,
subscribeToJobEvents,
type ThreadIndexSummary,
type UpdateRagDefaultsRequest,
uploadKBDocument,
uploadThreadDocument,
} from "../api/rag-api";
@ -57,6 +61,10 @@ interface RagStoreState {
reingestKB: (kbId: string, opts?: ReingestKBOptions) => Promise<string[]>;
reingestThread: (threadId: string) => Promise<string[]>;
defaults: RagDefaults | null;
loadDefaults: () => Promise<void>;
updateDefaults: (patch: UpdateRagDefaultsRequest) => Promise<void>;
subscribeJob: (jobId: string, onComplete?: () => void) => void;
}
@ -82,6 +90,8 @@ export const useRagStore = create<RagStoreState>((set, get) => ({
threadIndexes: [],
threadIndexesLoading: false,
defaults: null,
async loadKnowledgeBases() {
set({ kbsLoading: true, kbsError: null });
try {
@ -243,6 +253,22 @@ export const useRagStore = create<RagStoreState>((set, get) => ({
return response.job_ids;
},
async loadDefaults() {
try {
const defaults = await apiGetRagDefaults();
set({ defaults });
} catch {
// Defaults endpoint is best-effort; a 401 / network blip just
// leaves defaults null and dialogs fall back to hard-coded
// ('standard', 'text', no embedder override).
}
},
async updateDefaults(patch) {
const defaults = await apiSetRagDefaults(patch);
set({ defaults });
},
async reingestThread(threadId) {
const response = await apiReingestThread(threadId);
void get().loadThreadDocuments(threadId);

View file

@ -5,6 +5,7 @@ import { Separator } from "@/components/ui/separator";
import type { KnowledgeBase } from "@/features/rag/api/rag-api";
import { KBDetailPanel } from "@/features/rag/components/kb-detail-panel";
import { KBList } from "@/features/rag/components/kb-list";
import { RagDefaultsSection } from "@/features/rag/components/rag-defaults-section";
import { ThreadIndexList } from "@/features/rag/components/thread-index-list";
import { useState } from "react";
@ -38,6 +39,8 @@ export function KnowledgeBasesTab() {
</div>
<Separator />
<ThreadIndexList />
<Separator />
<RagDefaultsSection />
</div>
);
}