Commit graph

5,443 commits

Author SHA1 Message Date
Roland Tannous
c1bc79bc84 Studio: point RAG captioner at the pre-quantized Unsloth bnb-4bit repo 2026-05-26 21:52:17 +04:00
Roland Tannous
5883a2d6c6 Studio: load RAG captioner via Unsloth FastVisionModel (4-bit, native path) 2026-05-26 21:49:36 +04:00
Roland Tannous
9d6e893ed0 Studio: load RAG captioner in 4-bit via BitsAndBytesConfig 2026-05-26 21:47:38 +04:00
Roland Tannous
d0e894726b Studio: swap RAG captioner to Qwen3-VL-2B-Instruct (free-form, Vision2Seq-compatible) 2026-05-26 21:44:56 +04:00
Roland Tannous
38e22c15d7 Studio: bump llama-server prefill timeout to 300s + warm RAG embedder when RAG turns on 2026-05-26 21:24:31 +04:00
Roland Tannous
5351822ff8 Studio: default RAG mode to multimodal everywhere 2026-05-26 21:03:51 +04:00
Roland Tannous
c22fb88b4f Studio: composer + button uploads to the currently-selected RAG source (KB or thread) 2026-05-26 20:51:26 +04:00
Roland Tannous
7c1a09b350 Studio: VLM-caption figures at ingest + pass image hits to LLM + render in card 2026-05-26 20:17:52 +04:00
Roland Tannous
9d5719a475 Studio: widen doc source parts past SDK SourceMessagePart type 2026-05-26 17:58:03 +04:00
Roland Tannous
7c1f8efe99 Studio: render only LLM-cited RAG chunks as Source badges; globally unique chunk IDs 2026-05-26 17:52:58 +04:00
Roland Tannous
d6a5abb4ba Studio: render search_knowledge_base tool results as chunk cards 2026-05-26 16:23:54 +04:00
Roland Tannous
719bd38ddf Studio: cap RAG tool calls at 3 focused sub-queries per user turn 2026-05-26 16:04:04 +04:00
Roland Tannous
1f3a92cab9 Studio: force RAG tool path — disable prefetch, RAG-first tool order, must-call directive 2026-05-26 15:52:40 +04:00
Roland Tannous
3248052269 Studio: show RAG Mode/Chunking on fresh chats; lazy-init thread on change 2026-05-26 15:30:49 +04:00
Roland Tannous
ae013e3383 Studio: declare aui in SharedComposer for RAG attach flow 2026-05-26 15:24:56 +04:00
Roland Tannous
1b45656bf5 Studio: let RAG attach create the backend thread on first upload 2026-05-26 15:22:02 +04:00
Roland Tannous
04d4909ed6 Studio: format search_knowledge_base hits as fenced <chunk> blocks with score/page/tokens 2026-05-26 15:09:11 +04:00
Roland Tannous
86b52503dd Studio: trim verbose comments/docstrings across RAG code 2026-05-26 13:51:11 +04:00
Roland Tannous
3b477a816c Studio: add BM25/semantic/hybrid search-mode toggle to RAG settings 2026-05-26 12:39:50 +04:00
Roland Tannous
e48d836ffc Studio: default RAG source to thread documents in sidepanel 2026-05-26 12:11:38 +04:00
Roland Tannous
7b3a13fea4 Studio: address gemini-code-assist PR review
Four valid review comments from gemini-code-assist[bot] on #5759:

1. core/rag/bm25.py:_load — wrap bm25s.BM25.load + json.loads with
   specific exception handlers (FileNotFoundError, OSError,
   JSONDecodeError, ValueError) and log a warning instead of
   propagating a 500. Corrupt/partial bm25 dirs now degrade to
   empty-search rather than crashing the request.

2. core/rag/tool.py was importing _resolve_scope_embedder from
   routes/rag.py — a layering violation (core depending on
   routes). Move the resolver into a new core/rag/scope.py module
   along with the chat-settings key constants; routes/rag.py
   now re-imports it under the same name. Same behaviour, no
   cycle, one source of truth for the resolution logic.

3. core/rag/bm25.py:rebuild_index — call delete_scope before saving
   the new index so stale files from a previous build (or a
   bm25s naming change) never coexist with current files. The
   library's save() doesn't unlink files it doesn't write.

4. routes/rag.py:_save_upload was running f.write() synchronously
   inside an async def. Switch to anyio.open_file() so each chunk
   write runs in a worker thread instead of blocking the event
   loop on multi-MB uploads. Cleanup unlink happens after the
   async-with closes the handle so Windows is happy.

Skipped one (vector_store.py:133 'hasattr query_points' redundancy)
— that comment was on the pre-rewrite Qdrant code; the file is
now sqlite-vec backed and the hasattr check is gone.
2026-05-25 16:43:39 +04:00
Roland Tannous
005234c953 Studio: route RAG parent-process loggers through structlog
core/rag/db.py, vector_store.py, tool.py, bm25.py, and reranker.py
all run only in the FastAPI parent process. Switch their loggers
from Python stdlib to studio's structlog get_logger so their output
shows up in the same JSON stream as the rest of the backend (the
request_completed / RAG search lines).

embeddings.py and ingestion.py stay on stdlib because they execute
inside the mp.spawn ingestion subprocess, which doesn't inherit the
parent's structlog configuration.
2026-05-25 15:25:11 +04:00
Roland Tannous
2093fb1608 Studio: swap RAG vector store from Qdrant to sqlite-vec
asg017/sqlite-vec is Apache-2.0 and OSI-approved. Replaces
qdrant-client (~30 MB) with a small SQLite extension loaded into a
dedicated rag.db file. Single file holds RAG vectors; bm25s indexes
and chat-side studio.db are unaffected.

- New core/rag/db.py owns the rag.db connection and sqlite-vec load.
  Extension load runs once at first open. Process-wide singleton
  protected by a lock; check_same_thread=False + WAL handles the
  FastAPI thread pool.
- core/rag/vector_store.py keeps the same public API
  (ensure_collection / upsert_chunks / search / collection_exists /
  delete_scope / delete_document) so callers in routes/rag.py,
  core/rag/ingestion.py, core/rag/tool.py, and core/rag/retrieval.py
  don't change. ensure_collection is now a no-op; collection_exists
  returns True iff the scope has at least one indexed vector.
- search uses sqlite-vec's vec_distance_cosine and converts distance
  to similarity in [0, 1] so the per-scope min_score threshold
  semantics stay identical.
- Mixed-dim scopes coexist behind WHERE scope = ? — the per-scope
  embedder resolver guarantees one embedder per scope.
- requirements/rag.txt swaps qdrant-client for sqlite-vec.
- utils/paths/storage_roots.py drops rag_vectordb_root() (the old
  qdrant directory); rag.db lives directly under rag_root().
- Rewritten tests/python/test_rag_vector_store.py for the new
  semantics (collection_exists tracks populated scopes; new tests
  for filtered search and upsert conflict resolution).

Python build requirement: connection.enable_load_extension(True)
must be available. install.sh creates the venv via uv-managed
python-build-standalone, which is compiled with
--enable-loadable-sqlite-extensions, so this works on standard
installs. core/rag/db.py raises an actionable error on the rare
custom-interpreter case.
2026-05-25 15:13:59 +04:00
pre-commit-ci[bot]
b931b0039b [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-25 06:39:01 +00:00
Roland Tannous
0130c1d1ff Studio: query RAG with the same embedder that indexed the scope
The query was always going through the default text embedder
(bge-small, 384-d) regardless of how the scope was ingested. A
multimodal thread indexed by Qwen3-VL (2048-d) crashed at cosine
similarity with 'shapes (104,2048) and (384,) not aligned'.

Resolve the scope's embedder at search time:
  - kb_<id> -> rag_knowledge_bases.embedding_model column
  - thread_<id> -> thread settings (with fallback to defaults +
    RAG_EMBEDDER_MATRIX matrix lookup)

Pass it through retrieve_hybrid/retrieve_dense to embeddings.encode
so the query lands in the same vector space as the docs. Both the
/api/rag/search route and the search_knowledge_base tool use the
same resolver.
2026-05-24 22:06:14 +04:00
Roland Tannous
fe124f5b63 Studio: re-bump sentence-transformers pin for Qwen3-VL embedder
Qwen3-VL-Embedding-2B's modules.json references
sentence_transformers.base.modules, introduced after 5.2.0.
Unlike BGE-VL, Qwen3-VL has no fragile custom forward subclass to
break against the newer ST internals, so the bump should land
cleanly for the multimodal path.
2026-05-24 21:56:27 +04:00
Roland Tannous
8666ec9bd6 Studio: swap multimodal RAG embedder to Qwen3-VL-Embedding-2B
Built on Qwen3 (not CLIP), so the 77-token text cap that bit
BGE-VL-base is gone — long chunks embed losslessly. Loads via
vanilla SentenceTransformer with trust_remote_code, no custom
adapter needed. 2048-d shared text+image space.

Adds qwen-vl-utils>=0.0.14 to rag.txt for image preprocessing,
required by the Qwen3-VL embedder family.
2026-05-24 21:48:02 +04:00
Roland Tannous
810e3a80be Studio: truncate BGE-VL text inputs to CLIP's 77-token cap
BGE-VL inherits CLIP's 77-token text positional embedding table —
longer chunks crash inside the text model with a shape mismatch.
Pre-tokenize with truncation=True, max_length=77 and call
get_text_features directly so the high-level encode() (which does
not truncate) is bypassed. Log when truncation happens — text
chunks beyond the cap are silently cut, so multimodal mode is
lossy on the text channel. Image channel is unaffected.
2026-05-24 21:42:23 +04:00
Roland Tannous
ad6fb95dba Studio: load BGE-VL via transformers AutoModel, bypassing ST shim
BGE-VL's sentence-transformers shim (bge_vl_clip_transformer.py)
is coupled to specific ST internals and broke across both the 5.2
and 5.3 pin attempts. The canonical load path documented at
bge-model.com is transformers.AutoModel with trust_remote_code +
model.set_processor() + model.encode(text=/images=).

- Wrap that path in _BGEVLAdapter exposing the slice of
  SentenceTransformer API the ingester uses (encode for text or PIL
  images, get_sentence_embedding_dimension, best-effort tokenize).
- Restore BAAI/BGE-VL-base in RAG_EMBEDDER_MATRIX.
- Revert sentence_transformers pin to 5.2.0 — no longer relevant
  since the multimodal path no longer touches ST.
2026-05-24 21:35:09 +04:00
Roland Tannous
16c0367e84 Studio: swap multimodal RAG embedder to clip-ViT-B-32
BGE-VL-base ships a custom Transformer subclass that's tightly
coupled to a specific sentence-transformers internal API — it
imports a module path missing in older ST and overrides forward()
expecting a return shape that changed in newer ST. We can't pin ST
to BGE-VL's exact version without risking the training/inference
paths that use the same library, so swap the multimodal default to
the canonical clip-ViT-B-32 which sentence-transformers wraps
natively (no trust_remote_code, no shim, ST-version-independent).
Same 512-d shared text+image space.
2026-05-24 21:32:01 +04:00
Roland Tannous
d5e62df2b8 Studio: bump sentence-transformers pin for multimodal RAG (BGE-VL)
BGE-VL's custom modeling code imports
sentence_transformers.base.modules.transformer, which doesn't exist
in the 5.2.0 pin. Bump to >=5.3.0,<7 so the new Module package is
available.
2026-05-24 21:27:42 +04:00
Roland Tannous
da698cbdae Studio: pass trust_remote_code to RAG embedder loader
BGE-VL (multimodal) and nomic-embed-text-v1.5 (late chunking) both
ship custom modeling code in their model repos; sentence-transformers
refuses to import the referenced module (e.g. bge_vl_clip_transformer)
without trust_remote_code=True. Safe to enable because the embedder
matrix is config-pinned — users don't supply arbitrary names.
2026-05-24 21:14:22 +04:00
Roland Tannous
236ad37091 Studio: switch RAG dense search to qdrant query_points API
client.search() was removed/renamed to query_points() in
qdrant-client 1.10+. Use the new API when available and fall back
to search() for older pinned versions.
2026-05-24 18:27:49 +04:00
Roland Tannous
9ce194482b Studio: add ragMinScore to PersistedChatSettings type
tsc -b caught the missing key after the runtime-store addition.
2026-05-24 18:17:03 +04:00
Roland Tannous
ccebbed190 Studio: always pre-fetch RAG + min-score threshold + retrieval logging
- chat-adapter pre-fetches retrieval on every turn when RAG is on,
  regardless of provider. Users no longer have to phrase queries as
  'the document I attached' for retrieval to fire. Local tool models
  still get search_knowledge_base registered as a refinement path.
- New per-thread ragMinScore slider (Min relevance, 0..1) gates
  retrieved hits by dense cosine similarity. Hits below the floor
  (and BM25-only hits with no dense signal) are dropped server-side
  so unrelated docs don't get injected when the user's query is
  off-topic from what's indexed.
- Backend logs at search start (scope, top_k, min_score, query
  preview), after retrieval (retrieved vs met_threshold counts),
  and on return (final hit count) for both /api/rag/search and the
  search_knowledge_base tool path.
- System-prompt nudge prepended when pre-fetch returns hits so the
  model knows to cite [1], [2] rather than paraphrase silently.
2026-05-24 18:15:02 +04:00
Roland Tannous
e27c079e3d Studio: renumber install steps so RAG is independent step 9 2026-05-24 16:24:54 +04:00
Roland Tannous
6c694d2ef8 Studio: move RAG deps to dedicated rag.txt and install in normal path
no-torch-runtime.txt is only consumed in NO_TORCH (Intel Mac
GGUF-only) mode, so qdrant-client / bm25s / pymupdf etc. were never
installed in the normal install path. Split them into rag.txt and
add a step to install_python_stack.py that installs it after studio
deps, skipped only when NO_TORCH is set.
2026-05-24 16:23:26 +04:00
Roland Tannous
30856de739 Studio: route in-thread doc uploads through RAG ingest
When ragToolEnabled is on, the in-thread composer's + button now
opens a doc-only picker and uploads selected files to the per-thread
RAG pipeline (matching SharedComposer). Pending chips show
Uploading -> Indexing -> Ready status; Send is blocked while any
doc is in-flight. Previously these files were base64'd inline as
native attachments, which bypassed RAG entirely.
2026-05-24 16:13:05 +04:00
Roland Tannous
510509318a Studio: mirror RAG pill in in-thread composer
The Phase 4 RAG button only existed in shared-composer; the
assistant-ui in-thread composer renders its own pill set, so RAG
was missing once a thread had messages.
2026-05-24 15:59:57 +04:00
Roland Tannous
5edbc99916 Studio: stable empty-array sentinel in RAG document selectors
Fresh [] literals from useRagStore selectors triggered Zustand's
Object.is snapshot check on every render, causing React #185 on the
chat page when documentsByScope[key] was unpopulated.
2026-05-24 15:24:48 +04:00
Roland Tannous
810b2e27f3 Studio: fix React #185 update-depth loop in RAG additions
Two useEffects added in Phase 2C and Phase 4 followed the anti-pattern
of calling a state setter inside the effect and listing the setter's
output in the dep array. Both could re-fire indefinitely when the
selected slice changed shape on each render — which the chat page hit
on first load.

chat-settings-sheet.tsx (thread settings loader)
- Before: `useEffect(load, [..., threadSettings, ...])` with
  `if (!threadSettings) load()` inside. After load, the Zustand
  selector returned a freshly-constructed slice, dep changed,
  effect re-ran. If anything in between caused threadSettings to
  briefly flicker undefined (e.g. a race during initial hydration
  or a fast subsequent thread switch), the load fired again and the
  cycle repeated.
- After: ref-guarded by activeThreadId — `threadSettingsLoadedRef`
  tracks which threadId has been loaded; the effect deps shrink to
  `[ragSource.kind, activeThreadId, loadThreadSettings]`, all
  stable per-thread, removing the feedback loop.

ingestion-toast-stack.tsx (terminal-job auto-dismiss)
- Before: `useEffect(..., [jobs, dismissedJobs])` with
  `setDismissedJobs(prev => new Set(prev).add(jobId))` inside the
  scheduled setTimeout. Each setter creates a new Set reference;
  the dep change re-triggers the effect, which clears and
  reschedules timers. Under fast SSE event arrival or a strict-mode
  double-mount, the scheduler runs faster than its cleanup and
  React caps the depth.
- After: dismissedJobs is read via a ref (kept in sync at the top
  of the component); the effect only depends on `[jobs]`. A
  `scheduledJobsRef` prevents duplicate timer scheduling for the
  same job across multiple effect runs, and the setDismissedJobs
  updater no-ops when the job is already dismissed.

No behavior change for the happy path — toasts still auto-dismiss
after DISMISS_DELAY_MS; thread settings still load on first sight
of a thread.
2026-05-24 14:02:10 +04:00
Roland Tannous
a3ad6015bc Studio: fix tsc errors in Phase 4 frontend
Two errors surfaced by the frontend build (`tsc -b`) after the Phase 4
commit c74fc13eb landed:

  src/features/chat/chat-settings-sheet.tsx(449,9): TS2451 — cannot
  redeclare block-scoped 'activeThreadId'.
  src/features/rag/stores/rag-store.ts(326,9): TS2774 — condition will
  always return true since this function is always defined.

Fixes
- chat-settings-sheet.tsx: an `activeThreadId` declaration already
  existed near the code-exec section (line 636). Phase 2B's RAG
  retrieval-section block introduced a second declaration at line 449.
  The earlier one is needed for the Retrieval block; drop the later
  redeclaration — downstream code still resolves it via lexical scope.
- rag-store.ts subscribeJob: `get().jobUnsubscribers[jobId]` indexes a
  `Record<string, () => void>`. Without `noUncheckedIndexedAccess` TS
  infers the result as the function type (never undefined), so
  `if (existing)` is always-truthy. Replace with `if (jobId in
  get().jobUnsubscribers) return;` — same semantics, satisfies TS.
2026-05-24 13:47:34 +04:00
Roland Tannous
c74fc13ebc Studio: RAG-as-tool composer button (Phase 4)
Promotes RAG to a first-class composer toggle alongside Think / Web
Search / Code, with tool-use semantics on local models that support
tools and a pre-fetch fallback on external providers. The model
decides when to call `search_knowledge_base` on local inference; on
external providers retrieval still fires before each message (the
existing pre-fetch path), gated on the same button.

Backend
- core/rag/tool.py (new): search_knowledge_base handler + JSON-schema
  tool spec. Resolves scope (kb_id wins over thread_id) from the
  request's rag_scope, runs retrieve_hybrid + optional rerank, then
  hydrates filename / page_number / text from sqlite and formats as
  numbered Markdown citations ('[1] file.pdf (page 5): ...') for the
  LLM to cite. Empty scope returns a user-facing hint; empty results
  return a clear no-match message instead of an empty string.
- core/inference/tools.py: SEARCH_KNOWLEDGE_BASE_TOOL added to
  ALL_TOOLS (lazy import keeps tools.py importable on inference
  paths that never touch RAG). execute_tool() gains a tool_context
  parameter that carries per-request extras the LLM doesn't see
  (currently just rag_scope). The new 'search_knowledge_base' branch
  dispatches to the handler with scope unpacked from tool_context.
- core/inference/llama_cpp.py + safetensors_agentic.py +
  orchestrator.py: thread tool_context through generate_chat_completion_
  with_tools / run_safetensors_tool_loop / execute_tool. Both local
  backends (GGUF llama-server and safetensors agentic) carry the same
  context object.
- models/inference.py: ChatCompletionRequest gains optional
  rag_scope: dict ({kb_id?, thread_id?, enable_rerank?, default_top_k?,
  reranker_model?}). Ignored unless 'search_knowledge_base' is in
  enabled_tools.
- routes/inference.py: both the GGUF and safetensors call sites for
  generate_chat_completion_with_tools forward payload.rag_scope into
  tool_context.

Frontend
- chat-runtime-store.ts: global ragToolEnabled boolean + setter +
  CHAT_RAG_TOOL_ENABLED_KEY localStorage, mirroring toolsEnabled /
  codeToolsEnabled. Settings-hydration migration auto-flips
  ragToolEnabled=true for pre-Phase-4 users who already had ragSource
  set, so existing RAG users don't silently lose retrieval on upgrade.
- shared-composer.tsx: new 'RAG' pill button after Images (uses
  lucide BookOpenIcon, composer-pill-btn style, data-active toggle).
  Disabled when no model is loaded. Toggling on from ragSource='off'
  auto-flips source to 'thread' so the sidebar lands ready-to-go.
- chat-adapter.ts:
  * The existing pre-fetch block is now gated on ragToolEnabled AND
    only fires when the tool path isn't viable (external provider OR
    local model without tool-use support). Tool-capable local models
    skip pre-fetch and let the LLM decide.
  * The local-model body assembly adds 'search_knowledge_base' to
    enabled_tools and packs ragSource + enableRerank + ragTopK into a
    rag_scope object the backend tool handler consumes.
- chat-settings-sheet.tsx: entire Retrieval CollapsibleSection is
  wrapped in {ragToolEnabled && ...} so it hides when the button is
  off — the button is now the single on/off control. The 'Off'
  option is removed from the Source dropdown (the button handles
  that). Default open when shown so settings are one click away.

Tests
- test_rag_tool_handler.py: handler covers empty query, missing
  scope, kb_id > thread_id precedence, thread-only path, citation
  formatting (numbered + page numbers + unknown source); tool spec
  shape (function/name/required); execute_tool dispatch with and
  without tool_context; ALL_TOOLS includes the new spec without
  dropping the existing ones.

Verification scope
- Local GGUF with tools: toggle button on, upload doc, ask about
  doc content → assistant emits a search_knowledge_base tool call
  card (rendered by the existing ToolFallback component since no
  custom UI exists yet — that's a v2 nice-to-have).
- External provider (Anthropic / OpenAI / etc.): same button, same
  UX, but uses the pre-fetch path under the hood.
- Migration: pre-existing ragSource != off → button initializes ON
  so retrieval keeps working.
2026-05-24 13:41:45 +04:00
Roland Tannous
7e069816a1 Studio: end-to-end multimodal RAG integration test
@pytest.mark.server integration coverage that drives the ingestion
subprocess in-process and asserts the full multimodal pipeline
produces image + caption chunks with proper kind / image_path /
pair_group metadata. Default pytest runs skip; explicitly:

    pytest -m server tests/python/test_rag_multimodal_integration.py

Generates a small PDF (text + PNG figure + caption paragraph) via
pymupdf, points UNSLOTH_STUDIO_HOME at tmp_path, resets the embedder
singleton, and calls _subprocess_worker with mode='multimodal'.
Asserts:
- at least one chunk of each kind (text / image / caption) is emitted
- image chunks carry a real on-disk image_path under tmp_path
- image + caption chunks share a pair_group

A second test confirms BGE-VL produces text and image vectors of the
same dimension — sanity-check for the shared-space assumption that
the multimodal retrieval path relies on.

Downloads BGE-VL-base (~600 MB) on first run, so the test is gated
behind the existing server marker rather than the default suite.
2026-05-24 12:53:41 +04:00
Roland Tannous
ee1ff2bb50 Studio: per-thread RAG chunking/mode overrides
Threads can now opt into late chunking or multimodal mode independently
of the KBs they reference. Per-thread settings persist in chat_settings
under thread:<id>:rag and fall back to the app-level defaults when the
thread hasn't set anything explicitly.

Backend (routes/rag.py)
- ThreadRagSettings / UpdateThreadRagSettingsRequest Pydantic models.
- GET/PUT /api/rag/threads/{thread_id}/settings backed by
  chat_settings (upsert_chat_settings_merge). Same (multimodal, late)
  constraint enforcement as the create + defaults endpoints.
- POST /api/rag/threads/{thread_id}/reingest now accepts the same
  body shape — if any field is set, the new settings are persisted
  via set_thread_rag_settings BEFORE the reingest, so subsequent
  uploads pick up the change too.
- upload_thread_document reads the per-thread settings and passes
  them through to _start_ingestion, replacing the previous hard-coded
  ('standard', 'text', RAG_EMBEDDING_MODEL) defaults.

Frontend
- rag-api.ts: ThreadRagSettings type + getThreadRagSettings /
  setThreadRagSettings wrappers. reingestThreadDocuments now accepts
  optional UpdateThreadRagSettingsRequest opts.
- rag-store.ts: threadSettings map keyed by threadId, plus
  loadThreadSettings / updateThreadSettings actions. reingestThread
  refreshes the local settings copy when opts were supplied.
- chat-settings-sheet.tsx Retrieval section: when source = thread,
  shows side-by-side Mode + Chunking selects above the documents
  list. Selecting a different value:
    - persists immediately if the thread has no docs
    - prompts "Re-index N documents?" if docs exist; on Yes calls
      reingestThread with the new opts, on No reverts the select
  The (multimodal, late) constraint is enforced via per-option
  disabled + tooltip, matching the KB create dialog.
2026-05-24 12:53:28 +04:00
Roland Tannous
ca83bea538 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.
2026-05-24 12:47:16 +04:00
Roland Tannous
2b85a165e6 Studio: global RAG ingestion toast stack (Phase 2C)
Adds a floating progress-card stack mounted at the app root that
watches useRagStore.jobs and renders one card per in-flight ingestion
job, regardless of which page the user is on. Wraps the existing
IngestionProgress component so the progress UI stays consistent with
the per-doc chips in the KB detail panel and chat sidebar.

- Terminal cards (complete/error) linger for 4s then auto-dismiss.
- A manual dismiss button is always available.
- Reduced-motion preference is respected (no slide animation).
- Positioned bottom-right, z-50, pointer-events-none container so
  clicks pass through to the page underneath.

Mounted in app/routes/__root.tsx next to the existing SettingsDialog
so it's visible across every authenticated route. Closes the last
deferred item from Phase 2.
2026-05-24 12:45:00 +04:00
Roland Tannous
d9dfc7db80 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.
2026-05-24 12:41:55 +04:00
Roland Tannous
68114fd223 Studio: multimodal RAG mode (Phase 3B-multimodal)
When a KB has mode = 'multimodal', ingestion extracts images alongside
text and embeds both into a shared 512-d vector space via BGE-VL-base.
Image hits become first-class search results — useful for slides,
reports, and diagrams where text-only retrieval loses ~30-50% of the
content.

Backend
- embeddings.py: new encode_images(image_bytes_list) — opens bytes via
  PIL and routes to the SentenceTransformer (BGE-VL accepts PIL images
  in the same encode call as text).
- ingestion.py: _subprocess_worker gains document_id arg and a new
  _stream_image_chunks() helper. For multimodal KBs the standard text
  chunking runs first, then images are saved to
  rag_uploads_root() / 'images' / <document_id> / img-NNNN.<ext> and
  embedded; for each image with an adjacent caption, both an
  'image'-kind chunk (vector = encoded image) and a 'caption'-kind
  chunk (vector = encoded caption text) are streamed back with a
  shared pair_group field.
- ingestion.py parent: _insert_chunks_and_collect_for_bm25 now reads
  kind / image_path / pair_group from the subprocess message,
  populates the new rag_chunks columns, and runs a second pass that
  sets linked_chunk_id for each image ↔ caption pair. BM25 indexes
  text + caption chunks only — image chunks have no tokenisable body.
- retrieval.py: Hit gains a `kind` field plumbed through bm25, dense,
  RRF, and rerank paths.
- reranker.py: image-kind hits skip CrossEncoder rerank (text-only
  model) but are appended back in their original relative position
  rather than dropped.
- routes/rag.py: new GET /api/rag/images/{document_id}/{filename}
  static-file route with realpath containment check. SearchHit gains
  `kind` and `image_url` fields so the chat UI can render image
  thumbnails alongside text hits. KB-doc upload threads kind/mode
  through to ingestion.

Frontend
- rag-api.ts: SearchHit gains optional `kind` and `image_url`.
- kb-create-dialog.tsx: new Mode select (Text / Multimodal) alongside
  the existing Chunking strategy select. The forbidden
  (multimodal + late) combo is enforced in the UI — each side
  disables the conflicting option on the other side with a tooltip
  explaining why. Embedding-model placeholder cycles through the
  three valid defaults (bge-small / nomic / BGE-VL).
- kb-list.tsx + chat-settings-sheet.tsx: 🖼️ MM badge alongside the
   Late one so multimodal KBs are obvious at a glance.

Tests
- test_rag_multimodal.py: parser returns images when want_images=True
  and skips them when False; _validate_mode_combo rejects the
  forbidden (multimodal, late) pair with 400; RAG_EMBEDDER_MATRIX
  contains the three valid combos and excludes the forbidden one;
  image URL construction shape is verified. A server-marked test
  loads BGE-VL-base end-to-end and confirms image + text vectors
  share the same dimension.

Phase 3 of the plan is now feature-complete on the backend; the
remaining items (re-ingest UX for changing strategy on existing KBs)
are tracked under "Backfill UX" and can land separately.
2026-05-24 12:33:08 +04:00
Roland Tannous
673b7f86ba Studio: late chunking opt-in per KB (Phase 3B-late)
When a KB has chunking_strategy = 'late', ingestion takes a separate
code path that embeds the full document in a single forward pass and
mean-pools token embeddings per chunk span. Each chunk vector carries
full-document context via the encoder's bidirectional attention —
Jina's published technique, ~+6.5 nDCG@10 on long docs.

Backend
- chunking.py: new chunk_pages_with_spans() that joins all pages into a
  single full_doc, runs the existing recursive splitter, and returns
  per-chunk (char_start, char_end) offsets. Page-number metadata is
  recovered by overlap with the original page ranges so PDF citations
  still work. Existing chunk_pages() unchanged.
- embeddings.py: new late_chunk_encode(doc_text, char_spans). Tokenizes
  the doc with return_offsets_mapping, runs the underlying transformer
  to get per-token last_hidden_state, then mean-pools per chunk span.
  When the doc exceeds the embedder's context, falls back to windowed
  late chunking with a 512-token overlap so cross-window context is
  partially preserved.
- ingestion.py _subprocess_worker: branches on chunking_strategy.
  'late' path: chunk_pages_with_spans -> late_chunk_encode -> one big
  chunks_batch message. 'standard' path unchanged. Both reuse the same
  parent-side pump.
- ingestion.enqueue_ingestion: new chunking_strategy + mode kwargs;
  defaults to 'standard' / 'text' for legacy callers. embedder model
  resolved via resolve_embedder() from the (mode, strategy) matrix.
- routes/rag.py: KB-doc upload reads chunking_strategy + mode from the
  KB row (defensive .get for pre-Phase-3 schemas) and threads them
  through _start_ingestion.

Frontend
- kb-create-dialog.tsx: new "Chunking strategy" select with Standard /
  Late options. Embedding-model placeholder switches to nomic when
  Late is picked. createKB request now carries chunking_strategy.
- kb-list.tsx + chat-settings-sheet.tsx: small " Late" badge next to
  late-chunking KB names in the settings KB list and the chat sidebar
  dropdown so users see the mode at a glance.

Tests
- test_rag_late_chunking.py: pure-python tests for chunk_pages_with_spans
  (chunks index back into full_doc; page numbers inherited by overlap;
  pages joined with blank line). A server-marked test loads
  all-MiniLM-L6-v2 to exercise late_chunk_encode end-to-end.

No multimodal yet; that's Phase 3B-multimodal (next PR).
2026-05-24 12:18:09 +04:00