unsloth/studio/backend/core/rag/retrieval.py
Michael Han 4f24b12cc9
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>
2026-07-02 05:26:33 -07:00

100 lines
3.4 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Lexical (FTS5) + dense (vec0 cosine) retrieval fused via Reciprocal Rank
Fusion. ``dense_score`` is carried so callers can apply a similarity floor."""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from . import config, embeddings, store
@dataclass
class Hit:
chunk_id: str
score: float
lexical_score: float | None = None
dense_score: float | None = None
def retrieve_lexical(
conn: sqlite3.Connection,
scope: str | list[str],
query: str,
k: int | None = None,
) -> list[Hit]:
k = k or config.TOP_K_LEXICAL
return [Hit(cid, s, lexical_score = s) for cid, s in store.search_lexical(conn, scope, query, k)]
def retrieve_dense(
conn: sqlite3.Connection,
scope: str | list[str],
query: str,
k: int | None = None,
*,
model_name: str | None = None,
) -> list[Hit]:
k = k or config.TOP_K_DENSE
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]:
fused: dict[str, float] = {}
best: dict[str, Hit] = {}
for ranking in rankings:
for rank, hit in enumerate(ranking):
fused[hit.chunk_id] = fused.get(hit.chunk_id, 0.0) + 1.0 / (rrf_k + rank + 1)
cur = best.get(hit.chunk_id)
if cur is None:
best[hit.chunk_id] = Hit(hit.chunk_id, 0.0, hit.lexical_score, hit.dense_score)
else:
cur.lexical_score = (
cur.lexical_score if cur.lexical_score is not None else hit.lexical_score
)
cur.dense_score = (
cur.dense_score if cur.dense_score is not None else hit.dense_score
)
out: list[Hit] = []
for cid, s in sorted(fused.items(), key = lambda kv: kv[1], reverse = True)[:top_k]:
h = best[cid]
h.score = s
out.append(h)
return out
def retrieve_hybrid(
conn: sqlite3.Connection,
scope: str | list[str],
query: str,
*,
k: int | None = None,
model_name: str | None = None,
mode: str = "hybrid",
) -> list[Hit]:
"""``mode`` picks the backend: lexical-only, dense-only, or RRF of both
(default). Pool sizes and the RRF constant come from config."""
k = k if k is not None else config.TOP_K_HYBRID
k = int(k) # tool-call / scope top_k may arrive as a float; LIMIT + slice need int
if mode == "lexical":
return retrieve_lexical(conn, scope, query, k)
if mode == "dense":
return retrieve_dense(conn, scope, query, k, model_name = model_name)
lexical = retrieve_lexical(conn, scope, query, config.TOP_K_LEXICAL)
dense = retrieve_dense(conn, scope, query, config.TOP_K_DENSE, model_name = model_name)
return _rrf([lexical, dense], config.RRF_K, k)
def filter_min_score(hits: list[Hit], min_score: float) -> list[Hit]:
"""Cosine floor; gates only hits with a dense_score (lexical-only pass)."""
if min_score <= 0:
return hits
return [h for h in hits if h.dense_score is None or h.dense_score >= min_score]