From d9dfc7db8075f03099493c64ea1daa5f70506b62 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sun, 24 May 2026 12:41:55 +0400 Subject: [PATCH] Studio: re-ingest existing KBs / threads with new settings (Backfill UX) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- studio/backend/routes/rag.py | 156 +++++++++++++ .../src/features/chat/chat-settings-sheet.tsx | 50 +++-- .../frontend/src/features/rag/api/rag-api.ts | 40 ++++ .../rag/components/kb-detail-panel.tsx | 43 +++- .../rag/components/kb-reconfigure-dialog.tsx | 207 ++++++++++++++++++ .../src/features/rag/stores/rag-store.ts | 35 +++ tests/python/test_rag_reingest.py | 57 +++++ 7 files changed, 564 insertions(+), 24 deletions(-) create mode 100644 studio/frontend/src/features/rag/components/kb-reconfigure-dialog.tsx create mode 100644 tests/python/test_rag_reingest.py diff --git a/studio/backend/routes/rag.py b/studio/backend/routes/rag.py index dc3afee76d..dddb6c66b5 100644 --- a/studio/backend/routes/rag.py +++ b/studio/backend/routes/rag.py @@ -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, diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index 38ed0dff01..f6f0100d4d 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -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({ /> ))} - +
+ + +
)} diff --git a/studio/frontend/src/features/rag/api/rag-api.ts b/studio/frontend/src/features/rag/api/rag-api.ts index affa0b4ffc..d6c18faf3b 100644 --- a/studio/frontend/src/features/rag/api/rag-api.ts +++ b/studio/frontend/src/features/rag/api/rag-api.ts @@ -206,6 +206,46 @@ export async function clearThreadDocuments(threadId: string): Promise { 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 { + const response = await authFetch( + `/api/rag/knowledge-bases/${encodeURIComponent(kbId)}/reingest`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(opts), + }, + ); + return parseJsonOrThrow(response); +} + +export async function reingestThreadDocuments( + threadId: string, +): Promise { + const response = await authFetch( + `/api/rag/threads/${encodeURIComponent(threadId)}/reingest`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }, + ); + return parseJsonOrThrow(response); +} + // ------------------------------------------------------------------ // Search // ------------------------------------------------------------------ diff --git a/studio/frontend/src/features/rag/components/kb-detail-panel.tsx b/studio/frontend/src/features/rag/components/kb-detail-panel.tsx index 033803eacc..d8005135aa 100644 --- a/studio/frontend/src/features/rag/components/kb-detail-panel.tsx +++ b/studio/frontend/src/features/rag/components/kb-detail-panel.tsx @@ -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>( {}, ); + 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 (
-
-

{kb.name}

- {kb.description ? ( -

{kb.description}

- ) : null} -

- Embedding model: {kb.embedding_model} -

+
+
+

{kb.name}

+ {kb.description ? ( +

{kb.description}

+ ) : null} +

+ {kb.mode === "multimodal" ? "🖼️ Multimodal · " : ""} + {kb.chunking_strategy === "late" ? "⚡ Late · " : ""} + Embedder: {kb.embedding_model} +

+
+
@@ -82,6 +102,13 @@ export function KBDetailPanel({ kb }: { kb: KnowledgeBase }) { })}
+ +
); } diff --git a/studio/frontend/src/features/rag/components/kb-reconfigure-dialog.tsx b/studio/frontend/src/features/rag/components/kb-reconfigure-dialog.tsx new file mode 100644 index 0000000000..ac6d95c6a4 --- /dev/null +++ b/studio/frontend/src/features/rag/components/kb-reconfigure-dialog.tsx @@ -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( + kb.chunking_strategy, + ); + const [mode, setMode] = useState(kb.mode); + const [embeddingModel, setEmbeddingModel] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + +
+ + Reconfigure “{kb.name}” + + 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. + + +
+
+ + +
+
+ + +
+
+ + setEmbeddingModel(e.target.value)} + placeholder={placeholderEmbedder} + /> +

+ Leave blank to keep the current model (or pick the matrix + default when mode/strategy changes). +

+
+ {error ? ( +
{error}
+ ) : null} +
+ + + + +
+
+
+ ); +} diff --git a/studio/frontend/src/features/rag/stores/rag-store.ts b/studio/frontend/src/features/rag/stores/rag-store.ts index 1d777ad582..3cf9d98d66 100644 --- a/studio/frontend/src/features/rag/stores/rag-store.ts +++ b/studio/frontend/src/features/rag/stores/rag-store.ts @@ -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; clearThreadIndex: (threadId: string) => Promise; + reingestKB: (kbId: string, opts?: ReingestKBOptions) => Promise; + reingestThread: (threadId: string) => Promise; + subscribeJob: (jobId: string, onComplete?: () => void) => void; } @@ -220,6 +226,35 @@ export const useRagStore = create((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; diff --git a/tests/python/test_rag_reingest.py b/tests/python/test_rag_reingest.py new file mode 100644 index 0000000000..70508a2397 --- /dev/null +++ b/tests/python/test_rag_reingest.py @@ -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