Studio: customizable RAG embedding model with HF search, settings tab reorganization (#6800)

* Add customizable RAG embedding model setting and reorganize settings tabs

Chat with files, project sources, and knowledge bases previously always
embedded with unsloth/bge-small-en-v1.5. This adds a Settings option to
pick any Hugging Face embedding model (or local path), with HF search
autocomplete, server-side verification that the repo is actually an
embedding model, and a save anyway escape hatch for offline or local
models. The setting persists in app_settings and applies at runtime to
both the sentence-transformers and llama-server GGUF embedder backends
without a restart.

Also reorganizes the General settings tab: Documents & RAG sits above
Uploads, Helper LLM moved above the danger zone, and Model auto-switch
(OpenAI API) moved to the bottom of the API tab.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Support local model paths on the GGUF embedder and normalize default saves

Found by simulation testing of the embedding model setting:

Local paths saved as the embedding model now work on the llama-server
GGUF backend (the default backend on macOS and CPU). A path to a .gguf
file is used directly and a directory is scanned for a variant-matching
non-mmproj .gguf, with a clear error when none exists. Previously a
local path was sent to the HF hub API and failed with a repo lookup
error.

Saving the default model explicitly no longer stores an override, so
is_custom stays false and the UI does not show a reset button for the
default value.

* Address review: stale-vector handling, GGUF derivation, save-time guards

Review follow-ups, each verified by new tests:

Re-uploading a document after an embedding model change now re-indexes
instead of deduping by content hash. Documents record the embedder that
produced their vectors (lazy embedding_model column, NULL legacy rows
keep deduping) and a mismatch replaces the old document.

A vector width change no longer bricks the dense index. ensure_vec
drops and recreates chunks_vec when the dim changes (old vectors are in
a foreign space and only block inserts) and search_dense returns empty
on a width mismatch instead of surfacing a vec0 error, so lexical
search keeps working until documents are re-uploaded.

Saving a local sentence-transformers folder with no .gguf now returns
409 with a clear message when the install embeds via llama-server,
instead of failing at first index. force still saves.

A custom RAG_EMBEDDING_MODEL env without RAG_EMBED_GGUF_REPO now
derives the -GGUF companion repo instead of silently keeping the bge
GGUF on CPU and macOS installs.

The resolved GGUF path is tagged with the repo captured at entry, so a
setting change during a download cannot mark the old model as current.

GGUF repo detection matches gguf as a whole name segment rather than a
substring, hf_token is trimmed before verification, and the settings
combobox drops a redundant state mirror of its controlled value.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Shrink embedding model font to 11px in the input and dropdown

The combobox wrapper applies className to the outer input group, so the
size utility must target the inner input element; the previous text-xs
never reached it and the field rendered at the browser default.

* Show curated unsloth embedding models when the search field is empty

The empty-query listing was the global top-downloads page, which holds
no unsloth mirrors for the unsloth-first float to reorder, so the
dropdown opened on third-party models. Match the model picker: curated
unsloth listing when empty, whole-Hub search once a query is typed.

* Address review: settings resilience and index consistency

Keep the last known embedding model on settings store errors, remove the
re-entrant dim lock in the llama-server backend, accept local GGUF saves
and verify GGUF availability for HF repos on that backend, match local
path embedders exactly in model list filters, drop same-width stale
vectors from dense search, pin the embedder per ingestion job, and only
replace completed documents after the re-index succeeds.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Consolidate the GGUF repo derivation tests

* Trim to a single core embedding-model test

* Address review: GGUF repo saves and cache race

Accept a GGUF-named HF repo on the llama-server backend by verifying GGUF
availability instead of the sentence-transformers metadata gate, and guard
the settings cache with a generation counter so a read overlapping a save
cannot repopulate it with the pre-save value.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Michael Han 2026-07-02 05:26:33 -07:00 committed by GitHub
commit 4f24b12cc9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1072 additions and 75 deletions

View file

@ -6,8 +6,10 @@
from __future__ import annotations
import os
import re
EMBEDDING_MODEL = os.environ.get("RAG_EMBEDDING_MODEL", "unsloth/bge-small-en-v1.5")
DEFAULT_EMBEDDING_MODEL = "unsloth/bge-small-en-v1.5"
EMBEDDING_MODEL = os.environ.get("RAG_EMBEDDING_MODEL", DEFAULT_EMBEDDING_MODEL)
# Under bge's 512 limit, leaving headroom for the 2 special tokens (else overflow:
# llama-server 500s, ST truncates). Keep <= embedder_max - ~12.
CHUNK_TOKENS = int(os.environ.get("RAG_CHUNK_TOKENS", "500"))
@ -66,6 +68,43 @@ OCR_MAX_TOKENS = int(os.environ.get("RAG_OCR_MAX_TOKENS", "2048"))
# wins bulk indexing), else torch-free GGUF llama-server. Switching backends changes
# the vectors, so the index must be rebuilt.
EMBED_BACKEND = os.environ.get("RAG_EMBED_BACKEND", "auto")
def effective_embedding_model() -> str:
"""The embedding model actually in use: the persisted Settings override when
one is stored, else ``EMBEDDING_MODEL`` (env/default). Read at call time so a
Settings change applies without a restart."""
try:
from utils.embedding_model_settings import get_rag_embedding_model
return get_rag_embedding_model()
except Exception: # noqa: BLE001 - settings store unavailable (tests, early boot)
return EMBEDDING_MODEL
def _names_gguf(model: str) -> bool:
"""True when "gguf" appears as a whole name segment, so plain substrings
like "bigguf" don't count."""
return "gguf" in re.split(r"[^a-z0-9]+", model.lower())
def effective_gguf_repo() -> str:
"""GGUF repo for the llama-server backend, tracking the effective model.
An explicit ``RAG_EMBED_GGUF_REPO`` env always wins. Otherwise any custom
model (saved in Settings or via ``RAG_EMBEDDING_MODEL``) maps to its
``-GGUF`` companion repo (the unsloth convention the default pair follows),
or is used as-is when it already names a GGUF repo.
"""
if "RAG_EMBED_GGUF_REPO" in os.environ:
return EMBED_GGUF_REPO
model = effective_embedding_model()
if model == DEFAULT_EMBEDDING_MODEL:
return EMBED_GGUF_REPO
if _names_gguf(model):
return model
return f"{model}-GGUF"
# llama-server backend only. F16 over Q8_0: faster (no per-block dequant for this
# tiny model) and exact vs fp32, for ~30MB more on disk.
EMBED_GGUF_REPO = os.environ.get("RAG_EMBED_GGUF_REPO", "unsloth/bge-small-en-v1.5-GGUF")

View file

@ -55,9 +55,15 @@ class LlamaServerBackend:
self._port: int | None = None
self._stdout_lines: list[str] = []
self._stdout_thread: threading.Thread | None = None
# No lock: probes are idempotent (a duplicate 1-text encode is benign)
# and dim() -> encode() -> _ensure_ready() -> _resolve_model_path() can
# re-enter on a mid-probe model change, which would self-deadlock a
# non-reentrant lock held across the probe.
self._dim: int | None = None
self._dim_lock = threading.Lock()
self._model_path: str | None = None
# Effective GGUF repo the cached path/dim belong to; a Settings change
# makes it stale, forcing a re-resolve + respawn (see _ensure_ready).
self._model_repo: str | None = None
self._binary: str | None = None
# Sticky after an auto GPU start fails: later spawns stay on CPU.
self._force_cpu = False
@ -114,24 +120,77 @@ class LlamaServerBackend:
"RAG_EMBED_BACKEND=llama-server requires an embeddings-capable build"
)
@staticmethod
def _resolve_local_gguf(model: str) -> str | None:
"""A custom model may be a local .gguf file or a directory holding one;
resolve it without the hub. None when the value is not a local path."""
p = Path(model).expanduser()
if p.is_file() and p.suffix.lower() == ".gguf":
return str(p)
if p.is_dir():
files = [
f
for f in p.iterdir()
if f.suffix.lower() == ".gguf" and "mmproj" not in f.name.lower()
]
if not files:
raise RuntimeError(f"no .gguf file found in local model dir {model!r}")
variant = config.EMBED_GGUF_VARIANT.lower()
match = [f for f in files if variant in f.name.lower()] or files
return str(sorted(match, key = lambda f: len(f.name))[0])
return None
def _resolve_model_path(self) -> str:
"""Download (or cache-hit) the variant-matching, non-mmproj GGUF embedder,
returning its local path."""
if self._model_path is not None:
returning its local path. Re-resolves when the effective repo changed (a
custom model was saved in Settings)."""
# Captured once: if the setting changes mid-download, the path must stay
# tagged with the repo it was resolved FOR, so _current() sees the new
# setting as stale and respawns instead of serving the old model.
desired = config.effective_gguf_repo()
if self._model_path is not None and self._model_repo == desired:
return self._model_path
local = self._resolve_local_gguf(config.effective_embedding_model())
if local is not None:
self._model_path = local
self._model_repo = desired
self._dim = None
return self._model_path
from huggingface_hub import hf_hub_download, list_repo_files
repo = config.EMBED_GGUF_REPO
token = os.environ.get("HF_TOKEN") or None
files = [f for f in list_repo_files(repo, token = token) if f.lower().endswith(".gguf")]
files = [f for f in files if "mmproj" not in f.lower()]
# A custom model derives its "-GGUF" companion repo; when that guess does
# not exist, the model repo itself may host the .gguf files.
repo = desired
candidates = [repo]
model = config.effective_embedding_model()
if model != repo:
candidates.append(model)
files: list[str] = []
errors: list[str] = []
for candidate in candidates:
try:
files = [
f
for f in list_repo_files(candidate, token = token)
if f.lower().endswith(".gguf") and "mmproj" not in f.lower()
]
except Exception as e: # noqa: BLE001 - missing/gated repo -> next candidate
errors.append(f"{candidate!r}: {e}")
continue
if files:
repo = candidate
break
errors.append(f"{candidate!r}: no .gguf files")
if not files:
raise RuntimeError(f"no .gguf file found in embedder repo {repo!r}")
raise RuntimeError("no .gguf embedder found; tried " + "; ".join(errors))
variant = config.EMBED_GGUF_VARIANT.lower()
match = [f for f in files if variant in f.lower()] or files
filename = sorted(match, key = len)[0]
logger.info("resolving GGUF embedder %s/%s", repo, filename)
self._model_path = hf_hub_download(repo_id = repo, filename = filename, token = token)
self._model_repo = desired
self._dim = None
return self._model_path
# Min free VRAM (MiB) for the embedder; below this, auto stays on CPU.
@ -316,13 +375,19 @@ class LlamaServerBackend:
def _process_alive(self) -> bool:
return self._process is not None and self._process.poll() is None
def _current(self) -> bool:
"""Alive AND serving the effective repo (a Settings model change makes a
live server stale)."""
return self._process_alive() and self._model_repo == config.effective_gguf_repo()
def _ensure_ready(self) -> None:
"""Guarantee a live server, (re)spawning if needed. Double-checked so the
alive path takes no lock; self-heals after the chat reaper kills us."""
if self._process_alive():
"""Guarantee a live server on the effective model, (re)spawning if needed.
Double-checked so the current path takes no lock; self-heals after the
chat reaper kills us and re-resolves after a Settings model change."""
if self._current():
return
with self._lifecycle_lock:
if self._process_alive():
if self._current():
return
self._kill_process()
self._spawn()
@ -424,14 +489,18 @@ class LlamaServerBackend:
return arr
def dim(self, *, model_name = None) -> int:
"""Embedding width, probed once via a 1-text encode and cached."""
if self._dim is not None:
return self._dim
with self._dim_lock:
if self._dim is None:
vec = self.encode(["x"], normalize = False)
self._dim = int(vec.shape[1])
return self._dim
"""Embedding width, probed via a 1-text encode and cached per model
(_resolve_model_path clears it when the effective repo changes).
Unlocked: concurrent probes are benign, and locking would deadlock when
the probe's encode respawns onto a changed model (see __init__)."""
self._ensure_ready()
cached = self._dim
if cached is not None:
return cached
vec = self.encode(["x"], normalize = False)
width = int(vec.shape[1])
self._dim = width
return width
def warm(self, *, model_name = None) -> None:
"""Start the server and probe dim off the request path."""

View file

@ -67,7 +67,7 @@ def _get(model_name: str | None = None):
"""Cached SentenceTransformer, (re)loading on a name change. Loaded in fp16
for a ~1.5x speedup at negligible accuracy loss."""
global _model, _name
name = model_name or config.EMBEDDING_MODEL
name = model_name or config.effective_embedding_model()
with _lock:
if _model is None or _name != name:
_install_torchao_stub_once()

View file

@ -151,6 +151,19 @@ def _ocr_scanned_pages(
return out, ocred
def _replace_old_document(conn, replaces: tuple[str, str | None] | None, keep_path: str) -> None:
"""Drop the document this ingestion replaced (stale embedder / empty prior
ingest), called only after the replacement completed successfully."""
if replaces is None:
return
old_id, old_path = replaces
try:
store.delete_document(conn, old_id)
_remove_upload(old_path, keep_path = keep_path)
except Exception: # noqa: BLE001 - the new document is already live
logger.warning("failed to remove replaced document %s", old_id, exc_info = True)
def _run(
job_id: str,
document_id: str,
@ -159,6 +172,7 @@ def _run(
model_name: str | None,
ocr: bool | None = None,
caption: bool | None = None,
replaces: tuple[str, str | None] | None = None,
) -> None:
conn = rag_db.get_connection()
try:
@ -213,6 +227,7 @@ def _run(
)
if not chunks:
store.set_document_status(conn, document_id, "completed", num_chunks = 0)
_replace_old_document(conn, replaces, stored_path)
_set_job(conn, job_id, status = "completed", stage = "done", progress = 1.0)
_emit(job_id, {"type": "complete", "num_chunks": 0})
return
@ -233,6 +248,7 @@ def _run(
_progress(conn, job_id, "storing", 0.9)
store.add_chunks(conn, scope, document_id, chunks, vectors, regions)
store.set_document_status(conn, document_id, "completed", num_chunks = len(chunks))
_replace_old_document(conn, replaces, stored_path)
_set_job(conn, job_id, status = "completed", stage = "done", progress = 1.0)
_emit(job_id, {"type": "complete", "num_chunks": len(chunks)})
@ -274,17 +290,32 @@ def start_ingestion(
sha = _sha256_file(stored_path)
conn = rag_db.get_connection()
try:
effective_model = model_name or config.effective_embedding_model()
# (old_document_id, old_stored_path) replaced by this upload; deleted by
# the worker only after the replacement completes, so a failed re-index
# never destroys the still-searchable original.
replaces: tuple[str, str | None] | None = None
existing = store.document_by_hash(conn, scope, sha)
if existing is not None:
doc = store.get_document(conn, existing)
empty_completed = (
doc is not None and doc.get("status") == "completed" and not doc.get("num_chunks")
)
if empty_completed:
# Vectors from a different embedder are stale; re-uploading must
# re-index, not dedupe. NULL (legacy rows) is assumed current. Only
# completed rows are replaceable: a pending/running duplicate has a
# live worker whose writes must not land on a deleted document.
stale_model = (
doc is not None
and doc.get("status") == "completed"
and doc.get("embedding_model") is not None
and doc.get("embedding_model") != effective_model
)
if empty_completed or stale_model:
# A prior ingest of identical bytes yielded zero chunks (e.g. a scanned
# PDF uploaded before a vision model loaded). Re-ingest, don't dedupe.
store.delete_document(conn, existing)
_remove_upload(doc.get("stored_path"), keep_path = stored_path)
# PDF uploaded before a vision model loaded), or was embedded with a
# different model. Re-ingest, don't dedupe.
replaces = (existing, doc.get("stored_path"))
else:
job_id = _new_job(conn, existing, scope, status = "completed", progress = 1.0)
_remove_upload(stored_path)
@ -310,6 +341,7 @@ def start_ingestion(
project_id = project_id,
status = "pending",
stored_path = stored_path,
embedding_model = effective_model,
)
job_id = _new_job(conn, document_id, scope)
finally:
@ -319,7 +351,10 @@ def start_ingestion(
_jobs[job_id] = queue.Queue()
threading.Thread(
target = _run,
args = (job_id, document_id, scope, stored_path, model_name, ocr, caption),
# effective_model (not the raw model_name) pins the embedder for the
# whole job: a Settings change mid-ingestion must not switch tokenizer
# or embedder between batches of one document.
args = (job_id, document_id, scope, stored_path, effective_model, ocr, caption, replaces),
daemon = True,
).start()
return document_id, job_id

View file

@ -39,8 +39,12 @@ def retrieve_dense(
model_name: str | None = None,
) -> list[Hit]:
k = k or config.TOP_K_DENSE
vec = embeddings.encode([query], model_name = model_name, normalize = True)[0]
return [Hit(cid, s, dense_score = s) for cid, s in store.search_dense(conn, scope, vec, k)]
effective = model_name or config.effective_embedding_model()
vec = embeddings.encode([query], model_name = effective, normalize = True)[0]
return [
Hit(cid, s, dense_score = s)
for cid, s in store.search_dense(conn, scope, vec, k, embedding_model = effective)
]
def _rrf(rankings: list[list[Hit]], rrf_k: int, top_k: int) -> list[Hit]:

View file

@ -109,11 +109,12 @@ def create_document(
status: str = "pending",
stored_path: str | None = None,
document_id: str | None = None,
embedding_model: str | None = None,
) -> str:
document_id = document_id or str(uuid.uuid4())
conn.execute(
"INSERT INTO documents(id, scope, kb_id, thread_id, project_id, filename, sha256, "
"status, stored_path, created_at) VALUES(?,?,?,?,?,?,?,?,?,?)",
"status, stored_path, created_at, embedding_model) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(
document_id,
scope,
@ -125,6 +126,7 @@ def create_document(
status,
stored_path,
_now(),
embedding_model,
),
)
conn.commit()
@ -261,20 +263,50 @@ def search_lexical(conn: sqlite3.Connection, scope, query: str, k: int):
return [(r["chunk_id"], -r["s"]) for r in rows]
def search_dense(conn: sqlite3.Connection, scope, vector, k: int):
def search_dense(
conn: sqlite3.Connection,
scope,
vector,
k: int,
*,
embedding_model: str | None = None,
):
"""Cosine KNN over vec0 for one scope or several. Returns
[(chunk_id, 1 - distance)]. vec0 KNN constrains its partition key by
equality, so multi-scope runs one query per scope and merges by score."""
equality, so multi-scope runs one query per scope and merges by score.
``embedding_model`` drops hits from documents indexed under a different
(same-width) model, whose vectors live in another space; NULL-model legacy
documents are assumed current, matching the ingestion dedupe rule."""
if not rag_db.vec_table_exists(conn):
return []
dim = rag_db.vec_table_dim(conn)
if dim is not None and dim != len(vector):
# Embedding model switched widths and nothing re-indexed yet; the stale
# table cannot answer new-model queries (vec0 errors on the MATCH).
return []
# Over-fetch when filtering so stale-model hits don't starve the top-k.
fetch = k * 3 if embedding_model else k
out: list[tuple[str, float]] = []
for s in _scopes(scope):
rows = conn.execute(
"SELECT chunk_id, distance FROM chunks_vec "
"WHERE scope=? AND embedding MATCH ? ORDER BY distance LIMIT ?",
(s, _f32(vector), k),
(s, _f32(vector), fetch),
).fetchall()
out.extend((r["chunk_id"], 1.0 - r["distance"]) for r in rows)
if embedding_model and out:
ids = [cid for cid, _ in out]
placeholders = ",".join("?" * len(ids))
valid = {
r["id"]
for r in conn.execute(
f"SELECT c.id FROM chunks c JOIN documents d ON d.id=c.document_id "
f"WHERE c.id IN ({placeholders}) "
f"AND (d.embedding_model IS NULL OR d.embedding_model=?)",
(*ids, embedding_model),
).fetchall()
}
out = [t for t in out if t[0] in valid]
out.sort(key = lambda t: t[1], reverse = True)
return out[:k]

View file

@ -7,6 +7,7 @@ import asyncio
import hashlib
import json
import os
import re
import shutil
import sys
import uuid
@ -58,25 +59,51 @@ def _safe_is_dir(path) -> bool:
return False
# Hub repo id shape ("owner/name", no leading separator); anything else is
# treated as a local filesystem path.
_HF_REPO_ID_RE = re.compile(r"^[A-Za-z0-9][\w.\-]*/[\w.\-]+$")
def _is_hidden_model(*values: str | None) -> bool:
"""True if any id/path is the RAG embedding model (EMBEDDING_MODEL or
EMBED_GGUF_REPO basename) or the llama.cpp install validation probe
(ggml-org/models / stories260K), so pickers hide them (GGUF and non-GGUF).
None are usable chat models; the probe can be cached as a side effect of
installing the prebuilt llama-server and otherwise sorts smallest, so it
would be auto-selected."""
would be auto-selected. A local-path embedder is matched by exact resolved
path only: a generic basename like "model" must not substring-hide
unrelated chat models."""
from core.rag import config as rag_config
needles = (
rag_config.EMBEDDING_MODEL.split("/")[-1].lower(),
rag_config.EMBED_GGUF_REPO.split("/")[-1].lower(),
needles = [
# The validation probe's repo (matches the cached repo id) and its exact
# filename (matches the on-disk path). The filename carries the .gguf so
# it does not hide unrelated repos like ``user/stories260K-finetune-GGUF``.
"ggml-org/models",
"stories260k.gguf",
)
return any(v and any(n in v.lower() for n in needles) for v in values)
]
exact_paths: list[str] = []
for model in (
rag_config.effective_embedding_model(),
rag_config.effective_gguf_repo(),
):
if _HF_REPO_ID_RE.match(model):
needles.append(model.split("/")[-1].lower())
else:
resolved = _safe_resolve(Path(model).expanduser())
if resolved:
exact_paths.append(resolved.lower())
for v in values:
if not v:
continue
low = v.lower()
if any(n in low for n in needles):
return True
if exact_paths:
resolved = _safe_resolve(Path(v).expanduser())
if resolved and resolved.lower() in exact_paths:
return True
return False
def _safe_resolve(path: Path) -> Optional[str]:

View file

@ -167,7 +167,7 @@ def create_knowledge_base(
conn,
name = payload.name.strip(),
description = (payload.description or None),
embedding_model = config.EMBEDDING_MODEL,
embedding_model = config.effective_embedding_model(),
)
return {"id": kb_id, "name": payload.name.strip()}
finally:

View file

@ -4,7 +4,7 @@
from typing import Literal, Optional
from urllib.parse import unquote, urlsplit
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field, field_validator
from auth.authentication import get_current_subject
@ -47,6 +47,15 @@ from utils.preview_sharing_settings import (
get_preview_sharing_enabled,
set_preview_sharing_enabled,
)
from utils.embedding_model_settings import (
MAX_EMBEDDING_MODEL_LENGTH,
default_embedding_model,
get_rag_embedding_model,
get_stored_embedding_model,
reset_rag_embedding_model,
set_rag_embedding_model,
validate_embedding_model,
)
router = APIRouter()
@ -229,6 +238,186 @@ def update_openai_auto_switch_override(
return ModelOverridesResponse(overrides = get_model_overrides())
class EmbeddingModelPayload(BaseModel):
embedding_model: str = Field(..., min_length = 1, max_length = MAX_EMBEDDING_MODEL_LENGTH)
# Token for gated/private repos during verification (not stored).
hf_token: Optional[str] = Field(default = None, max_length = 512)
# Skip HF verification (offline installs, local paths HF can't see).
force: bool = False
class EmbeddingModelResponse(BaseModel):
embedding_model: str
default_embedding_model: str
is_custom: bool
def _embedding_model_response() -> EmbeddingModelResponse:
return EmbeddingModelResponse(
embedding_model = get_rag_embedding_model(),
default_embedding_model = default_embedding_model(),
is_custom = get_stored_embedding_model() is not None,
)
def _llama_backend_active() -> bool:
"""True when this install embeds via the llama-server (GGUF) backend."""
from core.rag import config as rag_config
from core.rag import embeddings
try:
raw = (rag_config.EMBED_BACKEND or "auto").strip().lower()
key = embeddings._resolve_auto() if raw in embeddings._AUTO_ALIASES else raw
except Exception: # noqa: BLE001 - backend probe must never block saving
return False
return key in embeddings._LLAMA_ALIASES
def _resolves_as_local_gguf(model: str) -> bool:
"""True when ``model`` is a local .gguf file or a directory holding one, so
a save on the llama-server backend needs no HF verification (the artifact
itself is the proof)."""
from core.rag.embed_llama_server import LlamaServerBackend
try:
return LlamaServerBackend._resolve_local_gguf(model) is not None
except Exception: # noqa: BLE001 - dir without .gguf, filesystem oddity
return False
def _local_gguf_backend_error(model: str) -> str | None:
"""409 detail when ``model`` is a local dir without a .gguf but this install
embeds via llama-server (macOS/CPU default), which needs one. A
sentence-transformers-only folder would verify fine yet fail at first index.
None when not applicable. ``force`` skips this check like HF verification."""
from pathlib import Path
if not Path(model).expanduser().is_dir():
return None
from core.rag.embed_llama_server import LlamaServerBackend
if not _llama_backend_active():
return None
try:
LlamaServerBackend._resolve_local_gguf(model)
return None
except RuntimeError:
return (
f"{model!r} contains no .gguf file, but this install embeds with the "
"llama-server backend which requires one. Add a GGUF file to the "
"folder or use a Hugging Face repo."
)
except Exception: # noqa: BLE001 - filesystem oddity: don't block saving
return None
def _hf_gguf_backend_error(model: str, hf_token: Optional[str]) -> str | None:
"""409 detail when the llama-server backend would find no .gguf for an HF
repo: neither the derived companion repo nor the repo itself has one. Saves
that verify as embedding models would otherwise fail at first index.
None when not applicable; ``force`` skips this like HF verification."""
from pathlib import Path
if Path(model).expanduser().exists():
return None # local paths are handled by the local checks
if not _llama_backend_active():
return None
from core.rag import config as rag_config
candidates = [model] if rag_config._names_gguf(model) else [f"{model}-GGUF", model]
try:
from huggingface_hub import list_repo_files
except Exception: # noqa: BLE001 - hub client unavailable: don't block saving
return None
for candidate in candidates:
try:
files = list_repo_files(candidate, token = hf_token)
except Exception: # noqa: BLE001 - missing/gated repo: try next candidate
continue
if any(f.lower().endswith(".gguf") and "mmproj" not in f.lower() for f in files):
return None
checked = " or ".join(repr(c) for c in candidates)
return (
f"No GGUF weights found in {checked}, but this install embeds with the "
"llama-server backend which requires them. Pick a model with a GGUF "
"companion repo or GGUF files in the repo itself."
)
@router.get("/embedding-model", response_model = EmbeddingModelResponse)
def get_embedding_model(
current_subject: str = Depends(get_current_subject),
) -> EmbeddingModelResponse:
return _embedding_model_response()
@router.put("/embedding-model", response_model = EmbeddingModelResponse)
def update_embedding_model(
payload: EmbeddingModelPayload, current_subject: str = Depends(get_current_subject)
) -> EmbeddingModelResponse:
"""Set the RAG embedding model. Unless ``force`` is set, the repo is verified
to be an embedding model via HF metadata; an unverifiable model (wrong type,
typo, gated repo, or no network) returns 409 so the UI can offer "save anyway".
Documents indexed under the previous model must be re-uploaded."""
from utils.models import is_embedding_model
try:
model = validate_embedding_model(payload.embedding_model)
except ValueError as exc:
raise log_and_http_error(
exc,
400,
safe_error_detail(exc, fallback = "Invalid embedding model."),
event = "settings.update_embedding_model_failed",
log = logger,
) from exc
# The env/default model needs no verification; saving it is a no-op override.
# A local GGUF on the llama-server backend is accepted as-is: it is exactly
# what the backend loads, and HF metadata cannot verify a local path.
if (
model != default_embedding_model()
and not payload.force
and not (_llama_backend_active() and _resolves_as_local_gguf(model))
):
hf_token = (payload.hf_token or "").strip() or None
from core.rag import config as rag_config
# A GGUF-named repo on the llama-server backend is loaded from its .gguf
# files, which rarely carry sentence-transformers metadata; verify the
# GGUF is available (below) rather than the ST embedding-metadata gate,
# which would wrongly 409 a valid online GGUF embedder.
gguf_named = _llama_backend_active() and rag_config._names_gguf(model)
if not gguf_named and not is_embedding_model(model, hf_token = hf_token):
raise HTTPException(
status_code = 409,
detail = (
f"Could not verify {model!r} as an embedding model on "
"Hugging Face (it may be the wrong model type, gated, or "
"you may be offline)."
),
)
gguf_error = _local_gguf_backend_error(model) or _hf_gguf_backend_error(model, hf_token)
if gguf_error:
raise HTTPException(status_code = 409, detail = gguf_error)
set_rag_embedding_model(model)
logger.info(
"settings.embedding_model_updated subject=%s model=%s forced=%s",
current_subject,
model,
payload.force,
)
return _embedding_model_response()
@router.delete("/embedding-model", response_model = EmbeddingModelResponse)
def reset_embedding_model(
current_subject: str = Depends(get_current_subject),
) -> EmbeddingModelResponse:
"""Clear the override, returning to the env/default model."""
reset_rag_embedding_model()
logger.info("settings.embedding_model_reset subject=%s", current_subject)
return _embedding_model_response()
class PreviewLinkRotateResponse(BaseModel):
rotated: bool = True

View file

@ -15,6 +15,7 @@ column type).
"""
import logging
import re
import sqlite3
import threading
@ -64,7 +65,8 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
error TEXT,
num_chunks INTEGER NOT NULL DEFAULT 0,
stored_path TEXT,
created_at TEXT NOT NULL
created_at TEXT NOT NULL,
embedding_model TEXT
);
CREATE INDEX IF NOT EXISTS idx_documents_scope ON documents(scope);
CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents(scope, sha256);
@ -107,6 +109,10 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
cols = {r[1] for r in conn.execute("PRAGMA table_info(documents)").fetchall()}
if "project_id" not in cols:
conn.execute("ALTER TABLE documents ADD COLUMN project_id TEXT")
# Lazy upgrade: which embedder produced a document's vectors (NULL = legacy,
# assumed current). Dedupe re-ingests when it no longer matches.
if "embedding_model" not in cols:
conn.execute("ALTER TABLE documents ADD COLUMN embedding_model TEXT")
def get_connection() -> sqlite3.Connection:
@ -143,9 +149,32 @@ def get_connection() -> sqlite3.Connection:
return conn
def vec_table_dim(conn: sqlite3.Connection) -> int | None:
"""Embedding width baked into ``chunks_vec``, or None when absent."""
row = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='chunks_vec'"
).fetchone()
if row is None or not row["sql"]:
return None
m = re.search(r"float\[(\d+)\]", row["sql"])
return int(m.group(1)) if m else None
def ensure_vec(conn: sqlite3.Connection, dim: int) -> None:
"""Create the dense ``chunks_vec`` table once the embedding dim is known
(vec0 bakes it into the column type). Idempotent; dim fixed per db."""
(vec0 bakes it into the column type). A width change (embedding model
switched in Settings) drops the table: the old vectors live in a foreign
space and would only block inserts, while lexical search keeps serving old
chunks until they are re-uploaded."""
existing = vec_table_dim(conn)
if existing is not None and existing != int(dim):
logger.warning(
"chunks_vec dim changed %d -> %d (embedding model switched); dropping "
"stale dense index. Re-upload documents to restore dense search.",
existing,
int(dim),
)
conn.execute("DROP TABLE chunks_vec")
conn.execute(
f"CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec USING vec0("
f"scope TEXT partition key, "

View file

@ -0,0 +1,55 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Test for the customizable RAG embedding model: a saved override becomes the
effective model and derives its GGUF companion for the llama-server backend."""
from pathlib import Path
import sys
import types as _types
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
import pytest
import utils.embedding_model_settings as ems
from core.rag import config as rag_config
@pytest.fixture
def settings_store(monkeypatch):
"""In-memory app_settings store patched under the module's lazy imports."""
import storage.studio_db as studio_db
store: dict = {}
monkeypatch.setattr(
studio_db, "get_app_setting", lambda key, fallback = None: store.get(key, fallback)
)
monkeypatch.setattr(
studio_db, "upsert_app_settings", lambda settings: store.update(settings) or store
)
ems._invalidate_cache()
yield store
ems._invalidate_cache()
def test_custom_model_overrides_default_and_derives_gguf(settings_store, monkeypatch):
"""The core contract: with nothing stored the default is in effect; a saved
custom model becomes the effective embedding model and derives its -GGUF
companion (what the llama-server backend loads); reset clears the override."""
monkeypatch.delenv("RAG_EMBED_GGUF_REPO", raising = False)
assert ems.get_rag_embedding_model() == rag_config.EMBEDDING_MODEL
assert rag_config.effective_gguf_repo() == rag_config.EMBED_GGUF_REPO
assert ems.set_rag_embedding_model(" org/my-embedder ") == "org/my-embedder"
assert rag_config.effective_embedding_model() == "org/my-embedder"
assert rag_config.effective_gguf_repo() == "org/my-embedder-GGUF"
assert ems.reset_rag_embedding_model() == rag_config.EMBEDDING_MODEL
assert ems.get_stored_embedding_model() is None

View file

@ -373,6 +373,8 @@ def test_ensure_ready_respawns_dead_process(monkeypatch):
def fake_spawn():
spawned["n"] += 1
b._process = _FakeProc(alive = True)
# _current() now also checks the served repo, so mark it current.
b._model_repo = config.effective_gguf_repo()
monkeypatch.setattr(b, "_spawn", fake_spawn)
b._ensure_ready()

View file

@ -0,0 +1,124 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Persisted RAG embedding-model override (Settings -> General).
The stored value takes precedence over the ``RAG_EMBEDDING_MODEL`` env default in
``core.rag.config``. Vectors from different models live in different spaces, so
documents already indexed under the old model must be re-uploaded after a change
(the UI warns about this).
"""
from __future__ import annotations
import threading
import time
from typing import Any
EMBEDDING_MODEL_SETTING_KEY = "rag_embedding_model"
MAX_EMBEDDING_MODEL_LENGTH = 512
# The effective model is consulted on the embedder hot path (once per embed /
# tokenize call during ingestion), so the stored value is cached briefly instead
# of hitting sqlite each time. Writes invalidate immediately in-process; other
# readers converge within the TTL.
_CACHE_TTL_S = 2.0
_cached: tuple[float, str | None] | None = None
# Bumped on every write/invalidate. A reader captures it before the DB read and
# only fills the cache if it is unchanged afterward, so a read that overlapped a
# save cannot repopulate the cache with the pre-save value for the whole TTL.
_generation = 0
_lock = threading.Lock()
def _invalidate_cache() -> None:
global _cached, _generation
with _lock:
_cached = None
_generation += 1
def default_embedding_model() -> str:
"""The env/default model from rag config (``RAG_EMBEDDING_MODEL`` or bge)."""
from core.rag import config
return config.EMBEDDING_MODEL
def _coerce_embedding_model(value: Any) -> str | None:
if not isinstance(value, str):
return None
cleaned = value.strip()
if not cleaned or len(cleaned) > MAX_EMBEDDING_MODEL_LENGTH:
return None
# Newlines/control chars are never valid in a repo id or path.
if any(ord(ch) < 32 for ch in cleaned):
return None
return cleaned
def validate_embedding_model(value: Any) -> str:
cleaned = _coerce_embedding_model(value)
if cleaned is None:
raise ValueError(
"Embedding model must be a Hugging Face repo id (e.g. "
"'unsloth/bge-small-en-v1.5') or a local model path, up to "
f"{MAX_EMBEDDING_MODEL_LENGTH} characters."
)
return cleaned
def get_stored_embedding_model() -> str | None:
"""The persisted override, or None when unset/invalid."""
global _cached
now = time.monotonic()
with _lock:
cached = _cached
if cached is not None and now - cached[0] < _CACHE_TTL_S:
return cached[1]
gen = _generation
try:
from storage.studio_db import get_app_setting
stored = get_app_setting(EMBEDDING_MODEL_SETTING_KEY, None)
except Exception:
# Transient store failure: keep the last known value instead of
# silently reverting the embed/search hot path to the default model,
# which would mix vector spaces mid-ingestion.
with _lock:
if _cached is not None:
_cached = (time.monotonic(), _cached[1])
return _cached[1]
return None
value = _coerce_embedding_model(stored)
with _lock:
# Only cache when no save landed while we were reading; otherwise this
# value may be pre-save, and caching it would mask the new one for the
# TTL. The next reader re-reads the committed value.
if _generation == gen:
_cached = (time.monotonic(), value)
return value
def get_rag_embedding_model() -> str:
"""Effective embedding model: persisted override, else env/default."""
return get_stored_embedding_model() or default_embedding_model()
def set_rag_embedding_model(value: Any) -> str:
parsed = validate_embedding_model(value)
from storage.studio_db import upsert_app_settings
# Saving the default is not an override; keeps is_custom (and the UI's
# reset affordance) honest.
stored = parsed if parsed != default_embedding_model() else None
upsert_app_settings({EMBEDDING_MODEL_SETTING_KEY: stored})
_invalidate_cache()
return parsed
def reset_rag_embedding_model() -> str:
"""Clear the override; returns the (env/default) model now in effect."""
from storage.studio_db import upsert_app_settings
upsert_app_settings({EMBEDDING_MODEL_SETTING_KEY: None})
_invalidate_cache()
return default_embedding_model()

View file

@ -0,0 +1,82 @@
// 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 { authFetch } from "@/features/auth";
import { readFastApiError } from "@/lib/format-fastapi-error";
export type EmbeddingModelSettings = {
embeddingModel: string;
defaultEmbeddingModel: string;
isCustom: boolean;
};
type ApiEmbeddingModelSettings = {
// biome-ignore lint/style/useNamingConvention: API schema
embedding_model: string;
// biome-ignore lint/style/useNamingConvention: API schema
default_embedding_model: string;
// biome-ignore lint/style/useNamingConvention: API schema
is_custom: boolean;
};
/** 409 from the backend: the model could not be verified as an embedding model
* (wrong type, gated repo, or offline). Retry with force to save anyway. */
export class EmbeddingModelVerificationError extends Error {}
function fromApi(settings: ApiEmbeddingModelSettings): EmbeddingModelSettings {
return {
embeddingModel: settings.embedding_model,
defaultEmbeddingModel: settings.default_embedding_model,
isCustom: settings.is_custom,
};
}
export async function loadEmbeddingModelSettings(): Promise<EmbeddingModelSettings> {
const res = await authFetch("/api/settings/embedding-model");
if (!res.ok) {
throw new Error(
await readFastApiError(res, "Failed to load embedding model setting"),
);
}
return fromApi(await res.json());
}
export async function updateEmbeddingModelSettings(
embeddingModel: string,
options?: { hfToken?: string; force?: boolean },
): Promise<EmbeddingModelSettings> {
const res = await authFetch("/api/settings/embedding-model", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
// biome-ignore lint/style/useNamingConvention: API schema
embedding_model: embeddingModel,
// biome-ignore lint/style/useNamingConvention: API schema
hf_token: options?.hfToken || null,
force: options?.force ?? false,
}),
});
if (res.status === 409) {
throw new EmbeddingModelVerificationError(
await readFastApiError(res, "Could not verify the embedding model"),
);
}
if (!res.ok) {
throw new Error(
await readFastApiError(res, "Failed to save embedding model"),
);
}
return fromApi(await res.json());
}
export async function resetEmbeddingModelSettings(): Promise<EmbeddingModelSettings> {
const res = await authFetch("/api/settings/embedding-model", {
method: "DELETE",
});
if (!res.ok) {
throw new Error(
await readFastApiError(res, "Failed to reset embedding model"),
);
}
return fromApi(await res.json());
}

View file

@ -0,0 +1,134 @@
// 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 {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Spinner } from "@/components/ui/spinner";
import type { PipelineType } from "@huggingface/hub";
import { useHubModelSearch } from "@/features/hub/hooks/use-hub-model-search";
import { useDebouncedValue } from "@/hooks";
import { type ReactElement, useMemo, useRef } from "react";
// HF pipeline filter for embedding models; matches the backend's
// is_embedding_model signals (sentence-similarity / feature-extraction).
const EMBEDDING_TASKS: readonly PipelineType[] = [
"sentence-similarity",
"feature-extraction",
];
type EmbeddingModelComboboxProps = {
value: string;
/** Fires on typing, selection, and Enter with the current text. */
onChange: (value: string) => void;
accessToken?: string;
disabled?: boolean;
placeholder?: string;
ariaLabel?: string;
className?: string;
};
export function EmbeddingModelCombobox({
value,
onChange,
accessToken,
disabled,
placeholder,
ariaLabel,
className,
}: EmbeddingModelComboboxProps): ReactElement {
const selectingRef = useRef(false);
const anchorRef = useRef<HTMLDivElement>(null);
// Fully controlled: the parent updates value on every keystroke, so the
// prop itself is the search query.
const debouncedQuery = useDebouncedValue(value);
const { results, isLoading } = useHubModelSearch(debouncedQuery, {
task: EMBEDDING_TASKS,
accessToken,
excludeGguf: true,
enabled: !disabled,
// Curated unsloth listing when empty (the global top-downloads page holds
// no unsloth mirrors to float); a typed query searches the whole Hub.
ownerScope: debouncedQuery.trim() ? "all" : "unsloth",
});
const items = useMemo(() => {
const ids = results.map((item) => item.id);
const selected = value.trim();
if (selected && !ids.includes(selected)) {
ids.push(selected);
}
return ids;
}, [results, value]);
return (
<div
ref={anchorRef}
className={className}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
if (!(event.target instanceof HTMLInputElement)) return;
event.preventDefault();
const typed = event.target.value.trim();
if (typed) {
onChange(typed);
} else if (items.length > 0) {
onChange(items[0]);
}
}}
>
<Combobox
items={items}
filteredItems={items}
filter={null}
value={value.trim() ? value : null}
onValueChange={(next) => onChange(next ?? "")}
onInputValueChange={(next) => {
if (selectingRef.current) {
selectingRef.current = false;
return;
}
onChange(next);
}}
itemToStringValue={(item) => item}
autoHighlight={true}
>
<ComboboxInput
className="h-8 w-full font-mono [&_input]:text-[11px]"
placeholder={placeholder}
aria-label={ariaLabel}
disabled={disabled}
/>
<ComboboxContent anchor={anchorRef}>
{isLoading ? (
<div className="flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground">
<Spinner className="size-3.5" />
Searching...
</div>
) : (
<ComboboxEmpty>No embedding models found</ComboboxEmpty>
)}
<ComboboxList>
{(id: string) => (
<ComboboxItem
key={id}
value={id}
onPointerDown={() => {
selectingRef.current = true;
}}
>
<span className="truncate font-mono text-[11px]">{id}</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
);
}

View file

@ -17,6 +17,7 @@ import { fetchApiKeys, revokeApiKey, type ApiKey } from "../api/api-keys";
import { ApiMonitorConsole } from "../components/api-monitor-console";
import { ApiKeyRow } from "../components/api-key-row";
import { CreateKeyForm } from "../components/create-key-form";
import { ModelAutoSwitchSection } from "../components/model-auto-switch-section";
import { KeyRevealCard } from "../components/key-reveal-card";
import { UsageExamples } from "../components/usage-examples";
@ -171,6 +172,8 @@ export function ApiKeysTab() {
<UsageExamples apiKey={revealed} />
<ModelAutoSwitchSection />
<Dialog open={revokeTarget !== null} onOpenChange={(o) => !o && setRevokeTarget(null)}>
<DialogContent className="max-w-md">
<DialogHeader>

View file

@ -41,6 +41,13 @@ import {
rotatePreviewLinks,
updatePreviewSharing,
} from "../api/preview-sharing";
import {
type EmbeddingModelSettings,
EmbeddingModelVerificationError,
loadEmbeddingModelSettings,
resetEmbeddingModelSettings,
updateEmbeddingModelSettings,
} from "../api/embedding-model";
import {
DEFAULT_UPLOAD_LIMIT_MB,
type UploadLimitSettings,
@ -48,7 +55,7 @@ import {
updateUploadLimitSettings,
} from "../api/upload-limit";
import { ChangePasswordDialog } from "../components/change-password-dialog";
import { ModelAutoSwitchSection } from "../components/model-auto-switch-section";
import { EmbeddingModelCombobox } from "../components/embedding-model-combobox";
import { SettingsRow } from "../components/settings-row";
import { SettingsSection } from "../components/settings-section";
import { StudioVersionSection } from "../components/studio-version-section";
@ -164,6 +171,16 @@ export function GeneralTab() {
const [revokePreviewOpen, setRevokePreviewOpen] = useState(false);
const [isRevokingPreview, setIsRevokingPreview] = useState(false);
const [modelsFolder, setModelsFolder] = useState<ModelsFolder | null>(null);
const [embeddingModel, setEmbeddingModel] =
useState<EmbeddingModelSettings | null>(null);
const [draftEmbeddingModel, setDraftEmbeddingModel] = useState("");
const [embeddingModelError, setEmbeddingModelError] = useState<string | null>(
null,
);
// Set after a 409 (unverifiable model); offers "Save anyway".
const [embeddingModelNeedsForce, setEmbeddingModelNeedsForce] =
useState(false);
const [isSavingEmbeddingModel, setIsSavingEmbeddingModel] = useState(false);
const draftRef = useRef(draftToken);
useEffect(() => {
@ -258,6 +275,27 @@ export function GeneralTab() {
};
}, [t]);
useEffect(() => {
let cancelled = false;
void loadEmbeddingModelSettings()
.then((settings) => {
if (cancelled) return;
setEmbeddingModel(settings);
setDraftEmbeddingModel(settings.embeddingModel);
})
.catch((error) => {
if (cancelled) return;
setEmbeddingModelError(
error instanceof Error
? error.message
: t("settings.general.rag.loadError"),
);
});
return () => {
cancelled = true;
};
}, [t]);
useEffect(() => {
let cancelled = false;
void loadModelsFolder()
@ -350,6 +388,58 @@ export function GeneralTab() {
}
};
const saveEmbeddingModel = async (force: boolean) => {
const trimmed = draftEmbeddingModel.trim();
if (!trimmed) {
setEmbeddingModelError(t("settings.general.rag.emptyError"));
return;
}
setIsSavingEmbeddingModel(true);
setEmbeddingModelError(null);
try {
const settings = await updateEmbeddingModelSettings(trimmed, {
hfToken: hfToken || undefined,
force,
});
setEmbeddingModel(settings);
setDraftEmbeddingModel(settings.embeddingModel);
setEmbeddingModelNeedsForce(false);
toast.success(t("settings.general.rag.saved"), {
description: t("settings.general.rag.reindexWarning"),
});
} catch (error) {
if (error instanceof EmbeddingModelVerificationError) {
setEmbeddingModelNeedsForce(true);
}
setEmbeddingModelError(
error instanceof Error
? error.message
: t("settings.general.rag.saveError"),
);
} finally {
setIsSavingEmbeddingModel(false);
}
};
const resetEmbeddingModel = async () => {
setIsSavingEmbeddingModel(true);
setEmbeddingModelError(null);
setEmbeddingModelNeedsForce(false);
try {
const settings = await resetEmbeddingModelSettings();
setEmbeddingModel(settings);
setDraftEmbeddingModel(settings.embeddingModel);
} catch (error) {
setEmbeddingModelError(
error instanceof Error
? error.message
: t("settings.general.rag.saveError"),
);
} finally {
setIsSavingEmbeddingModel(false);
}
};
const saveUploadLimit = async () => {
const parsed = Number(draftUploadLimit);
if (!Number.isInteger(parsed)) {
@ -500,38 +590,6 @@ export function GeneralTab() {
</SettingsRow>
</SettingsSection>
<SettingsSection title={t("settings.general.helperLlm.sectionTitle")}>
<SettingsRow
label={t("settings.general.helperLlm.preloadOnStartup")}
description={t(
"settings.general.helperLlm.preloadOnStartupDescription",
)}
>
<div className="flex flex-col items-end gap-1">
<Switch
checked={helperPrecache?.enabled ?? false}
disabled={
!helperPrecache ||
isSavingHelperPrecache ||
helperPrecache.disabledByEnv
}
onCheckedChange={(enabled) => void saveHelperPrecache(enabled)}
/>
{helperPrecache?.disabledByEnv ? (
<span className="max-w-[260px] text-right text-xs text-muted-foreground">
{t("settings.general.helperLlm.disabledByEnv")}
</span>
) : helperPrecacheError ? (
<span className="max-w-[260px] text-right text-xs text-destructive">
{helperPrecacheError}
</span>
) : null}
</div>
</SettingsRow>
</SettingsSection>
<ModelAutoSwitchSection />
<SettingsSection
title={t("settings.general.previewSharing.sectionTitle")}
>
@ -568,6 +626,77 @@ export function GeneralTab() {
</SettingsRow>
</SettingsSection>
<SettingsSection title={t("settings.general.rag.sectionTitle")}>
<SettingsRow
label={t("settings.general.rag.embeddingModel")}
description={t("settings.general.rag.embeddingModelDescription", {
defaultModel: embeddingModel?.defaultEmbeddingModel ?? "",
})}
>
<div className="flex flex-col items-end gap-1">
<div className="flex items-center gap-2">
<EmbeddingModelCombobox
value={draftEmbeddingModel}
onChange={(next) => {
setDraftEmbeddingModel(next);
setEmbeddingModelNeedsForce(false);
setEmbeddingModelError(null);
}}
accessToken={hfToken || undefined}
disabled={!embeddingModel}
placeholder={embeddingModel?.defaultEmbeddingModel ?? ""}
ariaLabel={t("settings.general.rag.embeddingModel")}
className="w-[220px]"
/>
<Button
variant="outline"
size="sm"
disabled={
!embeddingModel ||
isSavingEmbeddingModel ||
draftEmbeddingModel.trim() === embeddingModel.embeddingModel
}
onClick={() => void saveEmbeddingModel(false)}
>
{isSavingEmbeddingModel
? t("common.saving")
: t("common.save")}
</Button>
</div>
{embeddingModelError ? (
<span className="max-w-[300px] text-right text-xs text-destructive">
{embeddingModelError}
</span>
) : null}
<div className="flex items-center gap-2">
{embeddingModelNeedsForce ? (
<Button
variant="outline"
size="sm"
disabled={isSavingEmbeddingModel}
onClick={() => void saveEmbeddingModel(true)}
>
{t("settings.general.rag.saveAnyway")}
</Button>
) : null}
{embeddingModel?.isCustom ? (
<Button
variant="ghost"
size="sm"
disabled={isSavingEmbeddingModel}
onClick={() => void resetEmbeddingModel()}
>
{t("settings.general.rag.resetAction")}
</Button>
) : null}
</div>
<span className="max-w-[300px] text-right text-xs text-muted-foreground">
{t("settings.general.rag.reindexWarning")}
</span>
</div>
</SettingsRow>
</SettingsSection>
<SettingsSection title={t("settings.general.uploads.sectionTitle")}>
<SettingsRow
label={t("settings.general.uploads.maxUploadSize")}
@ -633,6 +762,36 @@ export function GeneralTab() {
</SettingsSection>
)}
<SettingsSection title={t("settings.general.helperLlm.sectionTitle")}>
<SettingsRow
label={t("settings.general.helperLlm.preloadOnStartup")}
description={t(
"settings.general.helperLlm.preloadOnStartupDescription",
)}
>
<div className="flex flex-col items-end gap-1">
<Switch
checked={helperPrecache?.enabled ?? false}
disabled={
!helperPrecache ||
isSavingHelperPrecache ||
helperPrecache.disabledByEnv
}
onCheckedChange={(enabled) => void saveHelperPrecache(enabled)}
/>
{helperPrecache?.disabledByEnv ? (
<span className="max-w-[260px] text-right text-xs text-muted-foreground">
{t("settings.general.helperLlm.disabledByEnv")}
</span>
) : helperPrecacheError ? (
<span className="max-w-[260px] text-right text-xs text-destructive">
{helperPrecacheError}
</span>
) : null}
</div>
</SettingsRow>
</SettingsSection>
<SettingsSection
title={t("settings.general.resetPreferences.sectionTitle")}
>

View file

@ -193,6 +193,20 @@ export const en = {
maxUploadSize: "Training dataset upload cap",
maxUploadSizeDescription: "Default is {defaultSize} MB.",
},
rag: {
sectionTitle: "Documents & RAG",
embeddingModel: "Embedding model",
embeddingModelDescription:
"Hugging Face model or local path used to index and search your documents. Default is {defaultModel}.",
reindexWarning:
"Only affects newly indexed documents. Re-upload existing ones after changing the model.",
emptyError: "Enter a Hugging Face model id or local path.",
loadError: "Failed to load the embedding model setting.",
saveError: "Failed to save the embedding model.",
saved: "Embedding model saved.",
saveAnyway: "Save anyway",
resetAction: "Reset to default",
},
storage: {
sectionTitle: "Storage",
modelsFolder: "Models folder",