Skip to content

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

Merged
shimmyshimmer merged 12 commits into
mainfrom
custom-rag-embedding-model
Jul 2, 2026
Merged

Studio: customizable RAG embedding model with HF search, settings tab reorganization#6800
shimmyshimmer merged 12 commits into
mainfrom
custom-rag-embedding-model

Conversation

@shimmyshimmer

Copy link
Copy Markdown
Member

What

Studio Chat with files, project sources, and knowledge bases always embedded documents with unsloth/bge-small-en-v1.5. This PR adds a Settings option so users can pick their own embedding model, plus a small reorganization of the settings tabs.

Embedding model setting

Settings -> General -> Documents & RAG has a new "Embedding model" field:

  • Search-as-you-type autocomplete backed by the HF Hub, filtered to embedding pipelines (sentence-similarity, feature-extraction), with unsloth models pinned first. Free text still works for exact ids and local paths.
  • On save the backend verifies the repo is actually an embedding model via HF metadata (is_embedding_model). Unverifiable models (wrong type, gated, offline) return 409 and the UI offers "Save anyway" for offline installs and local paths.
  • "Reset to default" restores the env/default model.
  • The UI warns that changing the model only affects newly indexed documents and existing ones must be re-uploaded, since vectors from different models are not compatible.

Backend

  • New utils/embedding_model_settings.py: persists rag_embedding_model in app_settings (same pattern as the upload limit setting), with validation and a short TTL cache since the value is consulted on the embedder hot path.
  • New endpoints: GET/PUT/DELETE /api/settings/embedding-model.
  • core/rag/config.py gains effective_embedding_model() and effective_gguf_repo(). Precedence: saved setting, then RAG_EMBEDDING_MODEL env, then the bge default. An explicit RAG_EMBED_GGUF_REPO env still wins for the GGUF repo.
  • The llama-server GGUF embedder (the active backend on macOS and CPU) now derives its repo from the effective model: a custom model maps to its -GGUF companion repo, falling back to the model repo itself if the companion does not exist. The subprocess tracks which repo it serves and respawns with a fresh dim probe when the setting changes, so no restart is needed. The sentence-transformers backend already reloaded on name change.
  • KB creation records the effective model and the model pickers hide the effective embedder instead of the hardcoded defaults.

Settings tab reorganization

  • Documents & RAG placed above Uploads in the General tab.
  • Helper LLM moved to the bottom of the General tab, just above the danger zone.
  • Model auto-switch (OpenAI API) moved from General to the bottom of the API tab.

Testing

  • 24 new tests in test_embedding_model_settings.py covering the settings module, effective config resolution including GGUF repo derivation, and all three routes (verified save, 409 on unverifiable, force bypass, reset).
  • New respawn-on-model-change test in test_rag_embed_llama_server.py; existing RAG suites (test_rag_embeddings, test_rag_embed_llama_server, test_rag_ingestion, test_rag_retrieval, test_rag_store, settings route tests) pass.
  • Frontend typecheck and i18n parity pass.
  • Exercised end to end against a running Studio: searched and saved nomic-ai/nomic-embed-text-v1.5 through the UI, confirmed a chat model is rejected with a clear error, confirmed force save and reset, and confirmed the persisted value drives both embedder backends.

Notes

There is currently no reindex pipeline, so switching models leaves previously indexed chunks in the old vector space until documents are re-uploaded. The UI states this next to the field. A follow-up could add a per-KB model check with a rebuild action.

…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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for customizable RAG embedding models, allowing users to configure and persist custom models via the settings. It includes backend utility functions for validation and caching, updated llama-server backend logic to dynamically resolve model paths and handle model changes, new API endpoints, frontend integration with a Hugging Face model search combobox, and comprehensive tests. The review feedback suggests several improvements, including refining GGUF repository detection to avoid false positives, optimizing the frontend combobox by removing redundant state, defensively handling null search results, and sanitizing Hugging Face access tokens across both the frontend and backend to prevent API client errors.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +95 to +96
return model
return f"{model}-GGUF"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Avoid using a simple raw substring check for 'gguf' to prevent false positives (e.g., matching a model name like bigguf-model or ggufy that is not actually a GGUF repo). Instead, split the repository name into segments using delimiters like dashes, underscores, and dots, and verify if the tag appears as a whole segment.

Suggested change
return model
return f"{model}-GGUF"
import re
if any(seg == "gguf" for seg in re.split(r"[-_.]", model.lower())):
return model
References
  1. When statically detecting model types or tags from repository IDs or names, avoid simple raw substring checks to prevent false positives. Instead, split the repository name into segments using delimiters like dashes, underscores, and dots, and verify if the tag appears as a whole segment.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current code already matches whole segments: _names_gguf splits on any non alphanumeric run (re.split r"[^a-z0-9]+"), which is a superset of the dash/underscore/dot delimiters suggested, so bigguf and ggufy do not match. Covered by test_effective_gguf_repo_ignores_gguf_substring.

Comment on lines +45 to +52
const [inputValue, setInputValue] = useState(value);
const selectingRef = useRef(false);
const anchorRef = useRef<HTMLDivElement>(null);
const debouncedQuery = useDebouncedValue(inputValue);

useEffect(() => {
setInputValue(value);
}, [value]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The local inputValue state and its corresponding useEffect to sync with the value prop are redundant because the component is fully controlled by the parent (which updates draftEmbeddingModel on every keystroke). This causes unnecessary double-renders on every keystroke. You can remove the inputValue state and useEffect entirely, and pass the value prop directly to useDebouncedValue.

Suggested change
const [inputValue, setInputValue] = useState(value);
const selectingRef = useRef(false);
const anchorRef = useRef<HTMLDivElement>(null);
const debouncedQuery = useDebouncedValue(inputValue);
useEffect(() => {
setInputValue(value);
}, [value]);
const selectingRef = useRef(false);
const anchorRef = useRef<HTMLDivElement>(null);
const debouncedQuery = useDebouncedValue(value);
References
  1. To reset a component's state when a specific prop changes, prefer using React's built-in key prop instead of manually tracking the previous prop value in state and triggering a state update during render.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed in an earlier commit: the combobox is fully controlled now, the inputValue state and sync effect were removed and value feeds useDebouncedValue directly.

Comment on lines +54 to +59
const { results, isLoading } = useHubModelSearch(debouncedQuery, {
task: EMBEDDING_TASKS,
accessToken,
excludeGguf: true,
enabled: !disabled,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Sanitize the accessToken before passing it to useHubModelSearch. If the token is a malformed string (such as 'undefined', 'null', or empty/whitespace), the Hugging Face API client may throw an error instead of gracefully falling back to anonymous access for public repositories.

Suggested change
const { results, isLoading } = useHubModelSearch(debouncedQuery, {
task: EMBEDDING_TASKS,
accessToken,
excludeGguf: true,
enabled: !disabled,
});
const sanitizedToken = useMemo(() => {
if (!accessToken) return undefined;
const trimmed = accessToken.trim();
if (!trimmed || trimmed.toLowerCase() === "undefined" || trimmed.toLowerCase() === "null") {
return undefined;
}
return trimmed;
}, [accessToken]);
const { results, isLoading } = useHubModelSearch(debouncedQuery, {
task: EMBEDDING_TASKS,
accessToken: sanitizedToken,
excludeGguf: true,
enabled: !disabled,
});

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The token comes from the chat runtime store, which trims on write, and useHubModelSearch passes it as a Bearer header only when truthy. A literal 'undefined'/'null' string cannot reach this prop from our store, so the extra sanitizing would be dead code. Leaving as is.

Comment on lines +61 to +68
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]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Defensively handle cases where results might be undefined or null (e.g., during error states or initial loading) to prevent a potential runtime TypeError when calling results.map.

Suggested change
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]);
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]);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useHubModelSearch initializes results to [] and never sets it to null or undefined (error paths keep the previous array), so the guard cannot fire. Leaving as is.

Comment thread studio/backend/routes/settings.py Outdated
Comment on lines +291 to +292
if model != default_embedding_model() and not payload.force:
if not is_embedding_model(model, hf_token = payload.hf_token or None):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Sanitize the hf_token before passing it to is_embedding_model. If the token is a malformed string (such as 'undefined', 'null', or whitespace), the Hugging Face API client may throw an error instead of gracefully falling back to anonymous access for public repositories.

    if model != default_embedding_model() and not payload.force:
        hf_token = payload.hf_token
        if hf_token:
            hf_token = hf_token.strip()
            if not hf_token or hf_token.lower() in ("undefined", "null"):
                hf_token = None
        if not is_embedding_model(model, hf_token = hf_token):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed in an earlier commit: the route trims the payload token and passes None when empty ((payload.hf_token or '').strip() or None). Covered by test_put_embedding_model_trims_hf_token. The 'undefined'/'null' literal handling would be new behavior not used elsewhere in the codebase, so leaving that part out.

from huggingface_hub import hf_hub_download, list_repo_files

repo = config.EMBED_GGUF_REPO
token = os.environ.get("HF_TOKEN") or None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Sanitize the HF_TOKEN environment variable before passing it to list_repo_files and hf_hub_download. If the token is a malformed string (such as 'undefined', 'null', or whitespace), the Hugging Face API client may throw an error instead of gracefully falling back to anonymous access for public repositories.

Suggested change
token = os.environ.get("HF_TOKEN") or None
token = os.environ.get("HF_TOKEN")
if token:
token = token.strip()
if not token or token.lower() in ("undefined", "null"):
token = None

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

os.environ.get('HF_TOKEN') or None is the existing pattern across the backend for this env var; special casing literal 'undefined'/'null' strings here would diverge from every other call site. Leaving as is for consistency.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 394d721039

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +132 to +135
candidates = [repo]
model = config.effective_embedding_model()
if model != repo:
candidates.append(model)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject local paths before GGUF repo probing

When a user saves a local SentenceTransformer directory on a CPU/macOS install where RAG_EMBED_BACKEND=auto picks llama-server (checked core/rag/embeddings.py), the settings route accepts it because is_embedding_model returns true for local paths with modules.json. These new candidates then treat that filesystem path as a Hugging Face repo for list_repo_files/hf_hub_download, so the next document upload fails instead of using the configured embedder. Please either reject local ST paths for the GGUF backend or handle local GGUF/ST paths explicitly.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in earlier commits: _resolve_local_gguf handles local files/dirs without hub probing, and the settings route returns 409 when a local dir has no .gguf on the llama-server backend (test_put_local_dir_without_gguf_on_llama_backend_409). A dir without GGUF now fails resolution with a clear error instead of being treated as a hub repo.

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 = config.effective_gguf_repo()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid retagging downloads with a later setting

If the embedding setting changes while this HF download/listing is in progress, this line rereads the global setting and can tag the just-downloaded old model path as belonging to the new effective repo. _current() will then consider the live server current for the new setting even though _spawn_once started it with the old model_path, so queries keep using the wrong vectors until another restart. Capture the effective repo key once at the start of resolution and store that key after the download instead of rereading it here.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in an earlier commit: _resolve_model_path captures desired = config.effective_gguf_repo() once at entry and tags _model_repo with that captured key after the download, so a mid-download settings change leaves the server marked stale and _ensure_ready respawns. Covered by test_resolve_tags_path_with_repo_captured_at_entry.

"you may be offline)."
),
)
set_rag_embedding_model(model)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rebuild the vec table when switching dimensions

If any document has already created chunks_vec with the default model's dimension, saving a different-width embedder here (for example a 768-dim model after the 384-dim default) leaves the sqlite-vec table at the old embedding float[dim] because ensure_vec uses CREATE VIRTUAL TABLE IF NOT EXISTS. Subsequent re-indexing with the new model will still write vectors into the old-dimension table and fail; the setting needs to reject dimension changes with existing vectors or rebuild/drop the vec index as part of the switch.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in an earlier commit: ensure_vec now drops and recreates chunks_vec when the requested dimension differs from the existing column (with a warning log), and search_dense returns [] on a width mismatch instead of erroring. Covered by test_ensure_vec_rebuilds_on_dim_change and test_dense_dim_mismatch_returns_empty.

Comment on lines +201 to +202
reindexWarning:
"Only affects newly indexed documents. Re-upload existing ones after changing the model.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the re-upload instruction actually re-index

This tells users to re-upload existing documents after changing the model, but start_ingestion dedupes completed documents by scope and SHA before chunking/embedding, returns the old document, and deletes the new upload. In that scenario following this instruction does not regenerate vectors under the new embedder; either include the embedding model in the dedupe key/rebuild path or tell users they must delete the old document first.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in an earlier commit: start_ingestion records embedding_model per document and treats a completed duplicate indexed under a different model as stale, re-ingesting instead of deduping, so the re-upload instruction now actually re-indexes. Covered by test_reupload_after_model_change_reingests. As of 1a79a63 the old document is also kept until the replacement completes.

Comment on lines +243 to +244
# Token for gated/private repos during verification (not stored).
hf_token: Optional[str] = Field(default = None, max_length = 512)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reuse the HF token when loading private embedders

This token is only used for the metadata verification call; the actual embedding loaders are not given it (LlamaServerBackend falls back to os.environ.get('HF_TOKEN'), and the SentenceTransformer path loads by model name only). A gated/private embedding repo can therefore verify and save with the UI token, then fail on the first indexing request unless the same token also exists in the server environment; persist or thread the token through the later download/load path, or don't accept it for verification-only saves.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. Threading the UI token into the later loaders means persisting a credential server side, which this PR deliberately avoids (the payload token is verification-only and not stored). Gated repos work by setting HF_TOKEN in the server environment, which both loaders already read. Leaving as a documented limitation rather than quietly persisting tokens.

Comment thread studio/backend/core/rag/config.py Outdated
Comment on lines +93 to +94
if model == EMBEDDING_MODEL:
return EMBED_GGUF_REPO

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive the GGUF repo for env-selected defaults

When RAG_EMBEDDING_MODEL is set to a custom model but RAG_EMBED_GGUF_REPO is not set, EMBEDDING_MODEL already equals that custom value, so this branch returns the hardcoded/default GGUF repo instead of deriving <custom>-GGUF. On CPU/macOS where auto selects llama-server, users who configure only RAG_EMBEDDING_MODEL will still index/search with the bge GGUF model rather than their configured embedder.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in an earlier commit: effective_gguf_repo only short circuits when RAG_EMBED_GGUF_REPO is explicitly set; otherwise it compares against DEFAULT_EMBEDDING_MODEL, so an env-selected RAG_EMBEDDING_MODEL derives its -GGUF companion. Covered by test_env_custom_model_derives_companion.

…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.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

Ran a dedicated simulation pass over this branch in an isolated uv venv (Python 3.12, minimal deps, throwaway UNSLOTH_STUDIO_HOME so no real install is touched). 84 simulation tests across six areas:

  • Value fuzzing against the real sqlite store: validation boundaries (512 char limit, control chars, wrong JSON types), unicode ids, Windows and POSIX and UNC paths, corrupt and non-JSON rows, trim behavior, repeated overwrites, TTL cache expiry and write invalidation.
  • Concurrency: 16 threads mixing get/set/reset, hot-path readers racing a writer, cross-process writes via child processes, and parallel writers from three processes with no sqlite lock errors surfacing.
  • Full effective_gguf_repo derivation matrix including env pin precedence and store-failure degradation to the env/default model.
  • llama-server lifecycle with the hub stubbed: respawn on model change with dim re-probe, no respawn when unchanged, companion repo fallback, mmproj filtering, variant selection and fallback, and a failed spawn staying retryable after the user fixes the setting.
  • Before/after simulation against main: extracted the main branch config from git and verified it has no runtime resolver (env-at-import only, restart required), that this branch returns byte-identical values on the default path, and that a saved setting now applies in-process with no restart. Setting > env > builtin precedence verified in a fresh subprocess.
  • Cross-platform and browser checks: the added backend lines contain no sys.platform, os.name, or separator assumptions and the TTL cache uses time.monotonic (safe across wall clock jumps); the new frontend files use no browser-specific APIs, so browser support is inherited from the shared Combobox and hub search code already shipped across the app.

Two real issues found and fixed in the follow-up commit:

  1. Local model paths did not work on the llama-server GGUF backend, which is the default backend on macOS and CPU. The path was sent to the hub API and failed with a repo lookup error. Local .gguf files and directories containing them now resolve directly, with a clear error when a local directory has no .gguf.
  2. Saving the default model explicitly stored it as an override, so is_custom flipped true and the UI showed a reset button for the default value. Saving the default is now a no-op override.

After the fixes: 84 simulation tests pass, the 63 branch tests plus surrounding RAG suites pass in both the real Studio venv and the clean sandbox venv, and the live server was restarted and re-verified end to end (default state, explicit default save staying non-custom, custom save, 409 on a non-embedding model, forced local path save, reset).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cf616ac7fc

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pin the embedding model for each ingestion job

When a user changes the embedding setting while an upload is running, this fallback to config.effective_embedding_model() is re-evaluated on each default token_counter/encode path because the RAG upload routes start ingestion without a model_name. The worker can therefore chunk with the old tokenizer and embed later batches with the new model (or mix embedder batches after _name reloads), corrupting vectors within a single document/KB. Capture the effective model once when the ingestion job starts and pass it through instead of reading the mutable setting on each batch.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. start_ingestion now resolves the effective model once and passes it into the worker thread, so chunk token counting and every embed batch use the model captured at job start regardless of settings changes mid-run. Fixed in 1a79a63, covered by test_ingestion_pins_model_across_setting_change.

Comment on lines +72 to +73
except Exception:
stored = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail closed when the embedder setting cannot be read

When a custom RAG embedder is saved and a transient app_settings read error occurs (for example while the settings DB is briefly unavailable), this handler turns the failure into None and then caches it, so effective_embedding_model() falls back to the default on the embed/search hot path for up to 2 seconds. That can make a running ingestion batch or a query vector use the default model against documents indexed with the custom model, corrupting or hiding results; keep the last known value or propagate the error instead of caching a default fallback.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. A store read failure now keeps the last known cached value (re-arming the TTL) instead of caching a None that reverts the hot path to the default model. Fixed in 1a79a63, covered by test_stored_value_survives_transient_read_error.

Comment thread studio/backend/routes/settings.py Outdated
Comment on lines +291 to +292
if model != default_embedding_model() and not payload.force:
if not is_embedding_model(model, hf_token = payload.hf_token or None):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify GGUF availability for llama-server saves

On CPU/macOS installs where RAG_EMBED_BACKEND=auto selects llama-server (or when that backend is configured explicitly), this check accepts any Hugging Face repo whose metadata says it is an embedding model, but the runtime then needs a .gguf file in either <model>-GGUF or the model repo. A normal SentenceTransformer repo with no GGUF companion will save successfully here and only fail on the next document upload/search when _resolve_model_path() cannot find a .gguf; either verify a GGUF candidate for the active backend or reject these saves unless forced with a clearer warning.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. The save route now also verifies GGUF availability for HF repos when the llama-server backend is active: it lists the derived -GGUF companion and the repo itself and returns 409 when neither has a .gguf (force still bypasses). Fixed in 1a79a63, covered by test_put_hf_repo_without_gguf_on_llama_backend_409 and test_hf_gguf_backend_error_checks_candidates.

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.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

Went through every review comment, verified each against the code, and pushed fixes for the ones that held up. Summary:

Implemented, each with new tests:

  • Codex P1, vec table dim change: correct. ensure_vec now drops and recreates chunks_vec when the embedding width changes (the 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 serving until documents are re-indexed. Covered by test_ensure_vec_rebuilds_on_dim_change and test_dense_dim_mismatch_returns_empty.
  • Codex, dedupe defeats re-upload: correct. Documents now record the embedder that produced their vectors (lazy embedding_model column on documents), and start_ingestion re-ingests instead of deduping when it no longer matches the effective model. NULL legacy rows keep the old dedupe behavior. Covered by test_reupload_after_model_change_reingests and test_reupload_with_legacy_null_model_still_dedupes. The re-upload instruction in the UI is accurate now.
  • Codex, local ST paths on the GGUF backend: correct. In addition to the earlier local .gguf resolution, the settings PUT now returns 409 with a clear message when the value is a local folder without a .gguf and the install embeds via llama-server; force still saves. Verified live on a macOS install (llama backend active).
  • Codex, mid-download retag race: correct. The resolved path is now tagged with the repo captured at entry, so a setting change during a download leaves the server stale and the next use respawns. Covered by test_resolve_tags_path_with_repo_captured_at_entry.
  • Codex, env-selected custom model keeps the bge GGUF: correct. Derivation now compares against the builtin default instead of the env-resolved EMBEDDING_MODEL, so RAG_EMBEDDING_MODEL alone derives its -GGUF companion on CPU and macOS. Covered by test_env_custom_model_derives_companion.
  • Gemini, gguf substring false positive: fair. Detection now matches gguf as a whole name segment (split on non-alphanumerics), so bigguf-model derives a companion instead of being treated as a GGUF repo.
  • Gemini, redundant inputValue state in the combobox: fair. The component is fully controlled, so the state mirror and its sync effect are gone and the prop feeds the debounced query directly.
  • Gemini, hf_token hygiene: partially applied. The route trims the token and treats whitespace-only as absent. The frontend already commits trimmed tokens from the settings field, and the results array from the search hook is always initialized, so the suggested null guard and the frontend token checks were not needed.

Not implemented:

  • Codex, threading the UI token into the embedding loaders for gated repos: the observation is right, but the loaders run server side at ingest time with no request context, so a real fix means persisting the user token server side. That is a security decision beyond this PR; today the documented path is setting HF_TOKEN in the server environment, which both loaders already honor. Happy to do a follow-up if we want token persistence.
  • Gemini, sanitizing the HF_TOKEN env var in the embedder: this predates the PR and the same pattern is used consistently across the backend; changing it only here would just add inconsistency.

Full test state after the fixes: 134 backend tests across the settings and RAG suites pass, the frontend typechecks and builds, and an end to end run on a live install (upload, chunk, real GGUF embed, dense and hybrid search, dedupe re-upload, local dir 409, reset) passes.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7d9905eea6

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +273 to +276
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 []

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don’t reuse same-width stale dense vectors

When switching between embedders with the same width (for example two 384-dim SentenceTransformer models), this dimension-only guard does not fire, so the query below still searches chunks_vec rows produced by the old model with a query vector from the new model. The new embedding_model value is stored only on documents and is not used by dense retrieval, so hybrid/dense results can mix incompatible vector spaces until every old document is re-uploaded; the dense index needs to be keyed, filtered, or rebuilt based on the effective embedding model, not just vector length.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. search_dense now takes the effective embedding model, over-fetches, and drops hits whose document was indexed under a different model (NULL legacy rows stay served, matching the dedupe rule), so same-width switches no longer mix vector spaces. Fixed in 1a79a63, covered by test_dense_filters_same_width_stale_model.

Comment thread studio/backend/routes/models.py Outdated
needles = (
rag_config.EMBEDDING_MODEL.split("/")[-1].lower(),
rag_config.EMBED_GGUF_REPO.split("/")[-1].lower(),
rag_config.effective_embedding_model().split("/")[-1].lower(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match local embedding paths exactly in model filters

When the saved embedding model is a local path with a generic basename such as /tmp/models/model, this turns model into a substring needle. _is_hidden_model is used by the local and cached model listing endpoints, so after saving that path it filters out unrelated chat models whose id or path merely contains model; local paths should be hidden by exact resolved path (or an exact repo id), not by basename substring.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. _is_hidden_model now distinguishes repo ids from local paths: repo ids keep the basename needle (cache paths embed it), while local paths are hidden by exact resolved path only, so a generic basename like model cannot substring-hide unrelated chat models. Fixed in 1a79a63, covered by test_is_hidden_model_local_path_matches_exactly.

Comment thread studio/backend/core/rag/ingestion.py Outdated
Comment on lines 291 to 295
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.
# PDF uploaded before a vision model loaded), or was embedded with a
# different model. Re-ingest, don't dedupe.
store.delete_document(conn, existing)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the old document until re-index succeeds

When a duplicate upload is considered stale because the embedding model changed, this deletes the existing completed document before the replacement worker has parsed, embedded, or stored anything. If the new model was force-saved incorrectly, is gated without a runtime token, or otherwise fails during ingestion, the previously searchable document and its stored upload are gone and the user is left with only a failed replacement; keep the old row/chunks until the new ingestion completes successfully or make the replacement atomic.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. The stale duplicate is no longer deleted up front: the replacement worker deletes the old document and its upload only after the new ingestion completes successfully, so a failed re-index leaves the searchable original intact. Fixed in 1a79a63, covered by test_failed_reingest_preserves_old_document.

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.
@shimmyshimmer
shimmyshimmer force-pushed the custom-rag-embedding-model branch from 52740dd to 519a1cc Compare July 2, 2026 06:26
@shimmyshimmer

Copy link
Copy Markdown
Member Author

Moved the fits-on-device model filter out of this PR into #6802 to keep this one scoped to the RAG embedding model setting. This branch now only contains the embedding model work.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 52740ddc52

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread studio/backend/routes/settings.py Outdated
# The env/default model needs no verification; saving it is a no-op override.
if model != default_embedding_model() and not payload.force:
hf_token = (payload.hf_token or "").strip() or None
if not is_embedding_model(model, hf_token = hf_token):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept local GGUF embedders before HF metadata checks

On llama-server installs, a valid local GGUF file or directory cannot be saved with the normal Save flow because this calls is_embedding_model before the new local-GGUF resolver is considered. That helper only returns true for local paths containing a SentenceTransformer modules.json, so GGUF-only paths always get a 409 and require “Save anyway” despite _resolve_local_gguf supporting them; check local GGUF paths before this HF/ST metadata gate so supported offline embedders are accepted without a misleading verification failure.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. When the llama-server backend is active and the value resolves as a local GGUF file or directory, the save now skips HF verification entirely (the artifact is what the backend loads), so no Save anyway is needed. Fixed in 1a79a63, covered by test_put_local_gguf_saves_without_verification.

Comment on lines +286 to +289
stale_model = (
doc is not None
and doc.get("embedding_model") is not None
and doc.get("embedding_model") != effective_model

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not delete in-flight stale duplicates

When the same file uploaded under model A is still pending/running and the user changes the embedder before uploading the same bytes again, stale_model becomes true here because the document status is not checked. start_ingestion then deletes the row while the first worker can still continue and insert chunks/FTS/vec rows for a document id that no longer exists, polluting retrieval indexes and leaving that job inconsistent; only completed rows should be treated as replaceable stale vectors, or the running job must be canceled/awaited.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. stale_model now requires the existing document to be completed, so a pending or running duplicate dedupes against the in-flight row instead of having it deleted under a live worker. Fixed in 1a79a63, covered by test_inflight_duplicate_not_deleted_on_model_change.

matchesCapability(row.capabilities, deferredCapabilityFilter) &&
(!activeChannel?.finetunableOnly || isUnslothFinetunable(row.result)),
(!activeChannel?.finetunableOnly || isUnslothFinetunable(row.result)) &&
(!fitOnDeviceOnly || hfModelFitsDevice(row.result, gpu)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply device-fit filtering to feed cards

On the default Discover feed (no query/section), this predicate only filters the Latest list via filteredDiscoverRows; the Trending cards passed to HubFeed are built separately from feedTrendingRows without checking fitOnDeviceOnly, so turning on the new toolbar toggle still shows oversized models above the filtered list. Apply the same fit predicate to the feed rows so the toggle actually hides non-fitting models throughout the Discover feed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is no longer part of this PR: the fits-on-device filter moved to #6802 to keep this one scoped to the embedding model setting. The feed/trending gap is fixed there (feedTrendingRows applies the same fit predicate).

Comment on lines +191 to +192
with self._dim_lock:
self._dim = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid re-entering the dim lock during respawn

When a Settings model change races with a llama-server dim() probe, dim() can already be holding _dim_lock while its internal encode() re-enters _ensure_ready() for the new repo; the respawn path then reaches this lock while the lifecycle restart is in progress, so that request can hang indefinitely (or lock-order deadlock with another restart). Clear the cached dimension without taking the same lock from _resolve_model_path, or avoid calling encode() while dim() holds it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. The dim lock is gone: probes are idempotent (a duplicate one-text encode is benign) and the cached width is now cleared and set by plain assignment, so dim() -> encode() -> _ensure_ready() -> _resolve_model_path() can no longer self-deadlock on a mid-probe model change. Fixed in 1a79a63.

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.
@shimmyshimmer
shimmyshimmer force-pushed the custom-rag-embedding-model branch from 519a1cc to 50477fa Compare July 2, 2026 08:23
shimmyshimmer and others added 2 commits July 2, 2026 01:44
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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 35c5d6fea1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor the pinned model in the llama backend

On llama-server installs (explicitly configured, or auto on CPU/macOS), ingestion now pins effective_model at job start, but this staleness check still follows the mutable global setting while the backend's encode/token_counter ignore their model_name argument. If the embedding setting changes during a multi-batch upload, _ensure_ready() can restart onto the new repo between tokenize/embed calls while the document row remains tagged with the old pinned model, mixing vector spaces. Fresh evidence is that this backend still compares _model_repo to config.effective_gguf_repo() instead of a caller-supplied model key.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real, deferring as a known limitation. Ingestion pins the model at job start, but the llama-server backend is a shared singleton whose encode/token_counter ignore model_name and follow the global effective repo, so a Settings change during an active multi-batch upload can respawn the subprocess between batches. Honoring the pinned model here means threading a caller-supplied vector-space key through _ensure_ready/_current (and deciding how concurrent jobs on different models share one subprocess), which is the same backend-keying change as the store/rag_db comments. It requires the user to change the model mid-upload, which the UI already advises against, so I am grouping it into that one follow-up rather than a partial fix. Noted.

# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fetch past stale dense hits before returning

When a same-width embedding switch leaves many old-model chunks in a scope, this applies the KNN LIMIT before filtering by documents.embedding_model. If the nearest 3 * k neighbors are all stale, search_dense returns fewer or even zero dense hits even though current-model chunks exist just after that prefix, so newly re-uploaded documents can remain undiscoverable until stale docs are deleted. Fresh evidence is that the fixed path uses a hard-coded k * 3 prefilter rather than filtering in SQL or continuing until it has k valid hits.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real gap, deferring with the same follow-up. The k*3 prefilter can miss current-model hits when more than 3k stale-model neighbors precede them. A fully correct fix needs the model filter pushed into the KNN itself (a vector-space partition/metadata column on chunks_vec) since sqlite-vec cannot post-filter a MATCH by a joined documents column without possibly starving the top-k. That is the per-model partitioning work tracked with the two comments below; I did not want to ship a wider-but-still-heuristic prefilter that only moves the boundary. Noted.

sha = _sha256_file(stored_path)
conn = rag_db.get_connection()
try:
effective_model = model_name or config.effective_embedding_model()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Key llama documents by the actual GGUF repo

When RAG_EMBED_GGUF_REPO is set, config.effective_gguf_repo() always returns that explicit repo, so the llama-server backend keeps producing the same vectors regardless of the Settings embedding-model label. Recording effective_embedding_model() here makes those same GGUF vectors look stale after any later Settings change, and dense search filters the old documents out even though they are still in the active vector space; use the backend's actual vector-space key (or block Settings changes under an explicit GGUF override) for the stored/filter model.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real edge case, deferring as a known limitation. It only triggers when RAG_EMBED_GGUF_REPO is explicitly pinned AND the Settings label is then changed: the vectors stay in the pinned GGUF space but the document gets keyed by the new label, so dense search filters them out. The correct fix is to key documents by the actual vector-space identifier (the GGUF repo on the llama backend, the model name on the ST backend) rather than the label, which needs a backend-aware key threaded through ingestion and search. That is the same partitioning change comments on embed_llama_server.py and rag_db.py point at, so I would rather do it as one deliberate change than bolt on a partial version here. Noted for a follow-up.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f87a757cd9

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread studio/backend/routes/settings.py Outdated
and not (_llama_backend_active() and _resolves_as_local_gguf(model))
):
hf_token = (payload.hf_token or "").strip() or None
if not is_embedding_model(model, hf_token = hf_token):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify GGUF repos before embedding metadata

When the active backend is llama-server and the user enters a Hugging Face GGUF repo id directly (for example a custom *-GGUF repo with embedding weights but without sentence-transformers/feature-extraction metadata), this metadata gate returns 409 before the GGUF-file check below can run. The runtime already treats GGUF-named models as loadable via effective_gguf_repo(), so a supported online GGUF repo incorrectly requires “Save anyway”; skip this metadata check or verify GGUF availability first for GGUF repo ids on the llama backend.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 5314b6536. On the llama-server backend a GGUF-named repo id now skips the sentence-transformers metadata gate (GGUF repos rarely carry that metadata) and is verified via GGUF availability instead, so a valid online GGUF embedder saves without Save anyway. Non-GGUF repos still go through the embedding-metadata check, so a plain chat repo is still rejected. Verified: a GGUF-named repo with is_embedding_model=False now returns 200, while a non-GGUF repo failing metadata still returns 409.

existing,
int(dim),
)
conn.execute("DROP TABLE chunks_vec")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent stale-width jobs from dropping current vectors

If a user switches to a different-width embedding model while an old upload is still running, a newer upload under the current model can create/populate chunks_vec first, and then the old pinned job can finish later and hit this DROP TABLE because its vector width differs. That deletes the dense vectors for the current-model document and leaves dense search returning no hits for the active model until another reindex; gate rebuilds on the job still matching the active vector-space key or store separate vec tables/partitions per model.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real, deferring with the same follow-up. If a width-changing model switch happens while an old-width upload is still running, the old job finishing late can hit ensure_vec and drop the current-model table. The safe fix is to gate the rebuild on the job still matching the active vector-space key, or per-model vec partitions, which is the backend-keying change tracked in the comments above. The sequential UI flow (change model, then re-upload) does not hit this; it needs a concurrent in-flight upload across a width change. Noted as a known limitation for the partitioning follow-up rather than a partial guard here.

return cached[1]
return None
value = _coerce_embedding_model(stored)
_cached = (now, value)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard cache fills against concurrent saves

When an embedding-model save races with a hot-path read that started just before the commit, that reader can fetch the old app_setting and then write it back into _cached after set_rag_embedding_model() has invalidated the cache. The PUT may then report the old model, or an immediate upload/search can use the old model for the full TTL after the user saved the new one; protect cache updates with a lock/generation so stale pre-save reads cannot repopulate the cache.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 5314b6536. get_stored_embedding_model now captures a generation counter (bumped by every write/invalidate) before the DB read and only fills the cache if the generation is unchanged afterward, so a read that overlapped a save can no longer repopulate the cache with the pre-save value for the TTL. Verified with a stub where a save commits and invalidates mid-read: the overlapping read returns the old value once, but the next read returns the new value instead of a poisoned cache.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 833e4553f2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +128 to +129
if p.is_file() and p.suffix.lower() == ".gguf":
return str(p)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject projector GGUF files for local embedders

When the saved value points directly at a local *mmproj*.gguf projector file, this branch accepts it as a valid local GGUF and the settings route skips HF verification because _resolves_as_local_gguf() succeeds. The directory and Hub paths both exclude mmproj, but a direct file path does not, so the next llama-server spawn will try to load a projector as the embedding model and fail after the setting has already been saved.

Useful? React with 👍 / 👎.

if _cached is not None:
_cached = (time.monotonic(), _cached[1])
return _cached[1]
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail closed when the first settings read fails

Fresh evidence after the earlier cache fix is that this no-cache exception path still returns None, so after a process restart (or before any successful read) a transient app_settings failure makes get_rag_embedding_model() fall back to the default model even if a custom embedder is persisted. If an upload or query hits that window, it can index/search with the default vector space instead of failing or preserving the stored override.

Useful? React with 👍 / 👎.

Comment on lines +90 to +91
if _HF_REPO_ID_RE.match(model):
needles.append(model.split("/")[-1].lower())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve local paths before repo-id substring hiding

Fresh evidence beyond the earlier basename fix is that an existing relative local path like models/model matches this repo-id regex before any filesystem resolution, so it is treated as an HF repo and adds model as a substring needle. After saving that local embedder, the local/cache model endpoints can again hide unrelated chat models whose id or path merely contains model; check for an existing local path before applying the repo-id heuristic.

Useful? React with 👍 / 👎.


try:
raw = (rag_config.EMBED_BACKEND or "auto").strip().lower()
key = embeddings._resolve_auto() if raw in embeddings._AUTO_ALIASES else raw

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify GGUF when auto can fall back to llama

On RAG_EMBED_BACKEND=auto with a visible GPU, this returns the sentence-transformers choice without exercising the same ST load/probe path that _get_backend() uses; if ST is broken and the real backend later falls back to llama-server, the settings save skips the GGUF availability check and can accept a normal embedding repo with no GGUF weights. The next upload then fails in the fallback backend while resolving <model>-GGUF, so auto detection here needs to account for the supported llama fallback or be conservative when it can occur.

Useful? React with 👍 / 👎.

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())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor explicit GGUF repo before local overrides

When RAG_EMBED_GGUF_REPO is set, config.effective_gguf_repo() says that env override always wins, but this still resolves the saved Settings value as a local GGUF before downloading desired. If a user saves a local .gguf, the backend serves that file while tagging _model_repo with the explicit repo; because desired stays constant, later Settings changes also hit the cache at lines 151-152 and keep serving the local file instead of the admin-configured repo.

Useful? React with 👍 / 👎.

@shimmyshimmer
shimmyshimmer merged commit 4f24b12 into main Jul 2, 2026
47 of 48 checks passed
@shimmyshimmer
shimmyshimmer deleted the custom-rag-embedding-model branch July 2, 2026 12:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant