Studio: re-ingest existing KBs / threads with new settings (Backfill UX)

Closes the upgrade-path gap from Phase 3: a KB or thread whose chunks
were ingested under one strategy can now be rebuilt under a different
one without losing the uploaded files.

Backend
- routes/rag.py:
  - POST /api/rag/knowledge-bases/{kb_id}/reingest takes optional
    chunking_strategy / mode / embedding_model in the body. Validates
    the (multimodal, late) constraint via _validate_mode_combo, updates
    the rag_knowledge_bases row, wipes scope artifacts (sqlite chunks
    via cascade, Qdrant collection, bm25), and re-INSERTs a fresh
    rag_documents row + ingestion job per stored file. Returns the new
    job IDs so callers can stream progress via the existing SSE.
  - POST /api/rag/threads/{thread_id}/reingest is the simpler thread
    variant — no body, rebuilds with current defaults.
  - Shared _reingest_scope helper strips the UUID upload prefix when
    re-naming docs so users see the original filenames again.

Frontend
- rag-api.ts: reingestKnowledgeBase(kbId, opts) and
  reingestThreadDocuments(threadId) wrappers + ReingestResponse type.
- rag-store.ts: reingestKB / reingestThread actions refresh the KB +
  doc lists and subscribe to every returned job so the existing
  IngestionProgress chips render without further wiring.
- kb-reconfigure-dialog.tsx (new): mirrors KBCreateDialog but
  pre-fills with the KB's current strategy / mode / embedder, enforces
  the same (multimodal + late) constraint with disabled options, and
  confirms before submitting. Submit label flips between "Re-index"
  (no settings change) and "Reconfigure & re-index".
- kb-detail-panel.tsx: header gains the chunking + mode summary and
  a "Reconfigure…" button that opens the dialog. Button is disabled
  when the KB has no documents.
- chat-settings-sheet.tsx Retrieval section: "Re-index" button beside
  the existing "Clear thread index" when the thread has documents.

Tests
- test_rag_reingest.py: ReingestKBRequest accepts optional fields,
  rejects unknown enum values via Pydantic, and the shared mode-combo
  guard still bites on the reingest path.
This commit is contained in:
Roland Tannous 2026-05-24 12:41:55 +04:00
commit d9dfc7db80
7 changed files with 564 additions and 24 deletions

View file

@ -407,6 +407,162 @@ def list_knowledge_bases(
return KBListResponse(knowledge_bases = [_row_to_kb(r) for r in rows])
class ReingestKBRequest(BaseModel):
"""All fields optional — omitting one keeps the KB's current value."""
chunking_strategy: ChunkingStrategy | None = None
mode: KBMode | None = None
embedding_model: str | None = None
class ReingestResponse(BaseModel):
job_ids: list[str]
document_ids: list[str]
def _reingest_scope(
*,
kb_id: str | None,
thread_id: str | None,
chunking_strategy: str,
mode: str,
embedding_model: str,
) -> ReingestResponse:
"""Wipe scope artifacts and re-enqueue every stored document.
The chat_settings / per-thread defaults aren't touched — caller is
responsible for updating any associated metadata before calling.
"""
scope = kb_scope(kb_id) if kb_id else thread_scope(thread_id) # type: ignore[arg-type]
with get_connection() as conn:
if kb_id:
rows = conn.execute(
"SELECT id, stored_path FROM rag_documents WHERE kb_id = ?",
(kb_id,),
).fetchall()
else:
rows = conn.execute(
"SELECT id, stored_path FROM rag_documents WHERE thread_id = ?",
(thread_id,),
).fetchall()
# Delete the rag_documents rows (cascade drops chunks); the
# uploaded file on disk is preserved so we can re-ingest from
# it. We re-INSERT a fresh row per stored_path below.
doc_ids = [r["id"] for r in rows]
if doc_ids:
placeholders = ",".join("?" for _ in doc_ids)
conn.execute(
f"DELETE FROM rag_documents WHERE id IN ({placeholders})",
doc_ids,
)
conn.commit()
ingestion.delete_scope_artifacts(scope)
job_ids: list[str] = []
new_doc_ids: list[str] = []
for row in rows:
stored_path = Path(row["stored_path"])
if not stored_path.is_file():
continue
filename = stored_path.name
# Strip the UUID prefix we attached at upload time so the
# re-inserted document carries the original name.
if "_" in filename:
_uuid_prefix, _, original = filename.partition("_")
if original:
filename = original
upload = _start_ingestion(
filename = filename,
stored_path = stored_path,
byte_size = stored_path.stat().st_size,
content_type = None,
kb_id = kb_id,
thread_id = thread_id,
embedding_model = embedding_model,
chunking_strategy = chunking_strategy,
mode = mode,
)
job_ids.append(upload.job_id)
new_doc_ids.append(upload.document_id)
return ReingestResponse(job_ids = job_ids, document_ids = new_doc_ids)
@router.post(
"/knowledge-bases/{kb_id}/reingest",
response_model = ReingestResponse,
)
def reingest_knowledge_base(
kb_id: str,
payload: ReingestKBRequest,
current_subject: str = Depends(get_current_subject),
) -> ReingestResponse:
from utils.rag.config import resolve_embedder
kb_row = _kb_or_404(kb_id)
keys = kb_row.keys() if hasattr(kb_row, "keys") else ()
current_strategy = (
kb_row["chunking_strategy"]
if "chunking_strategy" in keys
else "standard"
)
current_mode = kb_row["mode"] if "mode" in keys else "text"
current_embedder = kb_row["embedding_model"]
new_strategy = payload.chunking_strategy or current_strategy
new_mode = payload.mode or current_mode
_validate_mode_combo(new_mode, new_strategy)
new_embedder = (
payload.embedding_model
or (
current_embedder
if (new_strategy == current_strategy and new_mode == current_mode)
else resolve_embedder(new_mode, new_strategy)
)
)
with get_connection() as conn:
conn.execute(
"""
UPDATE rag_knowledge_bases
SET chunking_strategy = ?, mode = ?, embedding_model = ?
WHERE id = ?
""",
(new_strategy, new_mode, new_embedder, kb_id),
)
conn.commit()
return _reingest_scope(
kb_id = kb_id,
thread_id = None,
chunking_strategy = new_strategy,
mode = new_mode,
embedding_model = new_embedder,
)
@router.post(
"/threads/{thread_id}/reingest",
response_model = ReingestResponse,
)
def reingest_thread_documents(
thread_id: str,
current_subject: str = Depends(get_current_subject),
) -> ReingestResponse:
"""Rebuild a thread's RAG index using the current defaults.
No body per-thread strategy/mode overrides aren't exposed in v1.
"""
from utils.rag.config import RAG_EMBEDDING_MODEL
return _reingest_scope(
kb_id = None,
thread_id = thread_id,
chunking_strategy = "standard",
mode = "text",
embedding_model = RAG_EMBEDDING_MODEL,
)
@router.delete("/knowledge-bases/{kb_id}")
def delete_knowledge_base(
kb_id: str,

View file

@ -447,6 +447,7 @@ export function ChatSettingsPanel({
ragSource.kind === "thread" ? activeThreadId : null,
);
const clearThreadIndex = useRagStore((s) => s.clearThreadIndex);
const reingestThread = useRagStore((s) => s.reingestThread);
const [kbCreateOpen, setKbCreateOpen] = useState(false);
const ragEnabled = ragSource.kind !== "off";
const activeKbId = ragSource.kind === "kb" ? ragSource.kbId : null;
@ -1332,22 +1333,39 @@ export function ChatSettingsPanel({
/>
))}
</div>
<Button
variant="ghost"
size="sm"
className="self-start text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => {
if (
window.confirm(
`Delete all ${threadDocs.length} document${threadDocs.length === 1 ? "" : "s"} from this thread? This cannot be undone.`,
)
) {
void clearThreadIndex(activeThreadId);
}
}}
>
Clear thread index
</Button>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
if (
window.confirm(
`Re-index all ${threadDocs.length} document${threadDocs.length === 1 ? "" : "s"}? Existing chunks will be deleted and rebuilt; search will be unavailable until ingestion finishes.`,
)
) {
void reingestThread(activeThreadId);
}
}}
>
Re-index
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => {
if (
window.confirm(
`Delete all ${threadDocs.length} document${threadDocs.length === 1 ? "" : "s"} from this thread? This cannot be undone.`,
)
) {
void clearThreadIndex(activeThreadId);
}
}}
>
Clear thread index
</Button>
</div>
</>
)}
</div>

View file

@ -206,6 +206,46 @@ export async function clearThreadDocuments(threadId: string): Promise<void> {
await throwOnError(response);
}
export interface ReingestResponse {
job_ids: string[];
document_ids: string[];
}
export interface ReingestKBOptions {
chunking_strategy?: ChunkingStrategy;
mode?: KBMode;
embedding_model?: string;
}
export async function reingestKnowledgeBase(
kbId: string,
opts: ReingestKBOptions = {},
): Promise<ReingestResponse> {
const response = await authFetch(
`/api/rag/knowledge-bases/${encodeURIComponent(kbId)}/reingest`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(opts),
},
);
return parseJsonOrThrow<ReingestResponse>(response);
}
export async function reingestThreadDocuments(
threadId: string,
): Promise<ReingestResponse> {
const response = await authFetch(
`/api/rag/threads/${encodeURIComponent(threadId)}/reingest`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
},
);
return parseJsonOrThrow<ReingestResponse>(response);
}
// ------------------------------------------------------------------
// Search
// ------------------------------------------------------------------

View file

@ -1,6 +1,7 @@
// 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 { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { useState } from "react";
@ -9,12 +10,14 @@ import { useKBDocuments } from "../hooks/use-kb-documents";
import { DocumentRow } from "./document-row";
import { DocumentUploadDropzone } from "./document-upload-dropzone";
import { IngestionProgress } from "./ingestion-progress";
import { KBReconfigureDialog } from "./kb-reconfigure-dialog";
export function KBDetailPanel({ kb }: { kb: KnowledgeBase }) {
const { documents, loading, error, upload, remove } = useKBDocuments(kb.id);
const [activeJobsByDoc, setActiveJobsByDoc] = useState<Record<string, string>>(
{},
);
const [reconfigureOpen, setReconfigureOpen] = useState(false);
const handleFiles = async (files: File[]) => {
for (const file of files) {
@ -29,14 +32,31 @@ export function KBDetailPanel({ kb }: { kb: KnowledgeBase }) {
return (
<div className="flex h-full flex-col gap-4">
<div className="flex flex-col gap-1">
<h2 className="text-lg font-semibold">{kb.name}</h2>
{kb.description ? (
<p className="text-sm text-muted-foreground">{kb.description}</p>
) : null}
<p className="text-xs text-muted-foreground">
Embedding model: <code>{kb.embedding_model}</code>
</p>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1">
<h2 className="text-lg font-semibold">{kb.name}</h2>
{kb.description ? (
<p className="text-sm text-muted-foreground">{kb.description}</p>
) : null}
<p className="text-xs text-muted-foreground">
{kb.mode === "multimodal" ? "🖼️ Multimodal · " : ""}
{kb.chunking_strategy === "late" ? "⚡ Late · " : ""}
Embedder: <code>{kb.embedding_model}</code>
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setReconfigureOpen(true)}
disabled={documents.length === 0}
title={
documents.length === 0
? "Upload at least one document before re-indexing"
: undefined
}
>
Reconfigure
</Button>
</div>
<DocumentUploadDropzone onFiles={handleFiles} />
@ -82,6 +102,13 @@ export function KBDetailPanel({ kb }: { kb: KnowledgeBase }) {
})}
</div>
</ScrollArea>
<KBReconfigureDialog
open={reconfigureOpen}
onOpenChange={setReconfigureOpen}
kb={kb}
documentCount={documents.length}
/>
</div>
);
}

View file

@ -0,0 +1,207 @@
// 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 { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
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,
KnowledgeBase,
} from "../api/rag-api";
import { useRagStore } from "../stores/rag-store";
export function KBReconfigureDialog({
open,
onOpenChange,
kb,
documentCount,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
kb: KnowledgeBase;
documentCount: number;
}) {
const reingestKB = useRagStore((s) => s.reingestKB);
const [chunkingStrategy, setChunkingStrategy] = useState<ChunkingStrategy>(
kb.chunking_strategy,
);
const [mode, setMode] = useState<KBMode>(kb.mode);
const [embeddingModel, setEmbeddingModel] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Re-sync when the dialog opens against a different KB.
useEffect(() => {
if (open) {
setChunkingStrategy(kb.chunking_strategy);
setMode(kb.mode);
setEmbeddingModel("");
setError(null);
setSubmitting(false);
}
}, [open, kb.id, kb.chunking_strategy, kb.mode]);
const lateDisabled = mode === "multimodal";
const multimodalDisabled = chunkingStrategy === "late";
const placeholderEmbedder =
mode === "multimodal"
? `Current: ${kb.embedding_model}`
: chunkingStrategy === "late"
? `Current: ${kb.embedding_model}`
: `Current: ${kb.embedding_model}`;
const changedSettings =
chunkingStrategy !== kb.chunking_strategy ||
mode !== kb.mode ||
embeddingModel.trim() !== "";
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (submitting) return;
const verb = changedSettings ? "Reconfigure and re-index" : "Re-index";
if (
!window.confirm(
`${verb} ${documentCount} document${documentCount === 1 ? "" : "s"}? ` +
`Existing chunks will be deleted and rebuilt from the original files. ` +
`Search will be unavailable until ingestion finishes.`,
)
) {
return;
}
setSubmitting(true);
setError(null);
try {
await reingestKB(kb.id, {
chunking_strategy: chunkingStrategy,
mode,
embedding_model: embeddingModel.trim() || undefined,
});
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Reconfigure {kb.name}</DialogTitle>
<DialogDescription>
Change the chunking strategy, mode, or embedder for this KB.
All {documentCount} document{documentCount === 1 ? "" : "s"}{" "}
will be re-ingested from the originals on disk.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 py-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reconf-mode">Mode</Label>
<Select
value={mode}
onValueChange={(v) => setMode(v as KBMode)}
>
<SelectTrigger id="reconf-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 text + images
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="reconf-strategy">Chunking strategy</Label>
<Select
value={chunkingStrategy}
onValueChange={(v) => setChunkingStrategy(v as ChunkingStrategy)}
>
<SelectTrigger id="reconf-strategy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">
Standard heading-aware recursive splitter
</SelectItem>
<SelectItem
value="late"
disabled={lateDisabled}
title={
lateDisabled
? "Late chunking cannot be combined with multimodal mode"
: undefined
}
>
Late chunking single-pass embedder
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="reconf-model">Embedding model (optional)</Label>
<Input
id="reconf-model"
value={embeddingModel}
onChange={(e) => setEmbeddingModel(e.target.value)}
placeholder={placeholderEmbedder}
/>
<p className="text-[11px] text-muted-foreground">
Leave blank to keep the current model (or pick the matrix
default when mode/strategy changes).
</p>
</div>
{error ? (
<div className="text-xs text-destructive">{error}</div>
) : null}
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
Cancel
</Button>
<Button type="submit" disabled={submitting}>
{submitting
? "Re-indexing…"
: changedSettings
? "Reconfigure & re-index"
: "Re-index"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -15,6 +15,9 @@ import {
listThreadDocuments,
listThreadIndexes,
type RagDocument,
type ReingestKBOptions,
reingestKnowledgeBase as apiReingestKB,
reingestThreadDocuments as apiReingestThread,
subscribeToJobEvents,
type ThreadIndexSummary,
uploadKBDocument,
@ -51,6 +54,9 @@ interface RagStoreState {
loadThreadIndexes: () => Promise<void>;
clearThreadIndex: (threadId: string) => Promise<void>;
reingestKB: (kbId: string, opts?: ReingestKBOptions) => Promise<string[]>;
reingestThread: (threadId: string) => Promise<string[]>;
subscribeJob: (jobId: string, onComplete?: () => void) => void;
}
@ -220,6 +226,35 @@ export const useRagStore = create<RagStoreState>((set, get) => ({
});
},
async reingestKB(kbId, opts) {
const response = await apiReingestKB(kbId, opts ?? {});
// Refresh the KB list so updated chunking_strategy / mode / embedder
// values flow back into the UI, and the doc list so old chunk
// counts reset to 0 until each job completes.
void get().loadKnowledgeBases();
void get().loadKBDocuments(kbId);
// Subscribe to every new job so progress chips render and the doc
// list refreshes on completion (mirrors uploadDocument's pattern).
for (const jobId of response.job_ids) {
get().subscribeJob(jobId, () => {
void get().loadKBDocuments(kbId);
});
}
return response.job_ids;
},
async reingestThread(threadId) {
const response = await apiReingestThread(threadId);
void get().loadThreadDocuments(threadId);
void get().loadThreadIndexes();
for (const jobId of response.job_ids) {
get().subscribeJob(jobId, () => {
void get().loadThreadDocuments(threadId);
});
}
return response.job_ids;
},
subscribeJob(jobId, onComplete) {
const existing = get().jobUnsubscribers[jobId];
if (existing) return;

View file

@ -0,0 +1,57 @@
"""Reingest endpoint tests (Backfill UX).
Full end-to-end reingest needs a running studio + a real embedder; that's
covered manually via the curl smoke flow in the plan. Here we cover the
parts that are testable without external models: payload validation and
the (multimodal, late) constraint propagation.
"""
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
STUDIO_BACKEND = REPO_ROOT / "studio" / "backend"
if str(STUDIO_BACKEND) not in sys.path:
sys.path.insert(0, str(STUDIO_BACKEND))
def test_reingest_request_accepts_all_optional_fields():
from routes.rag import ReingestKBRequest
empty = ReingestKBRequest()
assert empty.chunking_strategy is None
assert empty.mode is None
assert empty.embedding_model is None
partial = ReingestKBRequest(chunking_strategy = "late")
assert partial.chunking_strategy == "late"
assert partial.mode is None
def test_reingest_request_rejects_unknown_strategy():
from routes.rag import ReingestKBRequest
from pydantic import ValidationError
with pytest.raises(ValidationError):
ReingestKBRequest(chunking_strategy = "telekinetic")
def test_reingest_request_rejects_unknown_mode():
from routes.rag import ReingestKBRequest
from pydantic import ValidationError
with pytest.raises(ValidationError):
ReingestKBRequest(mode = "augmented")
def test_constraint_still_enforced_for_reingest_combos():
"""The combination guard is shared with create — verify it still bites."""
from fastapi import HTTPException
from routes.rag import _validate_mode_combo
with pytest.raises(HTTPException) as excinfo:
_validate_mode_combo("multimodal", "late")
assert excinfo.value.status_code == 400