* 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>
259 lines
9.8 KiB
Python
259 lines
9.8 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
|
|
|
|
"""SQLite storage for the RAG engine.
|
|
|
|
Same pattern as providers_db.py / studio_db.py (module functions, raw sqlite3,
|
|
WAL, per-call connections, lazy schema), but every connection also loads
|
|
sqlite-vec (vec0 needs it per-connection). If it cannot load, RAG_AVAILABLE is
|
|
False and get_connection() raises rather than failing import.
|
|
|
|
One rag.db holds the ``documents`` / ``chunks`` model, the FTS5 lexical index
|
|
(``chunks_fts``) and the sqlite-vec dense index (``chunks_vec``, created lazily
|
|
by ensure_vec once the embedding dim is known, since vec0 bakes the dim into the
|
|
column type).
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
import sqlite3
|
|
import threading
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
from utils.paths import rag_db_path, ensure_dir
|
|
|
|
# Optional dep: import must never crash this module (imported unconditionally).
|
|
try:
|
|
import sqlite_vec
|
|
RAG_AVAILABLE = True
|
|
except Exception as exc: # noqa: BLE001 - any import failure disables RAG
|
|
sqlite_vec = None
|
|
RAG_AVAILABLE = False
|
|
logger.warning("RAG unavailable: sqlite-vec could not be imported (%s)", exc)
|
|
|
|
_RAG_UNAVAILABLE_MSG = "RAG unavailable: sqlite-vec extension could not be loaded"
|
|
|
|
_schema_lock = threading.Lock()
|
|
_schema_ready = False
|
|
|
|
|
|
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|
"""Create the RAG tables if absent (once per process). ``chunks_vec`` is
|
|
skipped: its column type needs the embedding dim, so ensure_vec() makes it
|
|
lazily at first ingest."""
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS knowledge_bases (
|
|
id TEXT NOT NULL PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
embedding_model TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS documents (
|
|
id TEXT NOT NULL PRIMARY KEY,
|
|
scope TEXT NOT NULL,
|
|
kb_id TEXT,
|
|
thread_id TEXT,
|
|
project_id TEXT,
|
|
filename TEXT NOT NULL,
|
|
sha256 TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
error TEXT,
|
|
num_chunks INTEGER NOT NULL DEFAULT 0,
|
|
stored_path TEXT,
|
|
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);
|
|
|
|
CREATE TABLE IF NOT EXISTS chunks (
|
|
id TEXT NOT NULL PRIMARY KEY,
|
|
document_id TEXT NOT NULL,
|
|
scope TEXT NOT NULL,
|
|
chunk_index INTEGER NOT NULL,
|
|
text TEXT NOT NULL,
|
|
page_number INTEGER,
|
|
source_page_index INTEGER,
|
|
token_count INTEGER,
|
|
kind TEXT NOT NULL DEFAULT 'text',
|
|
pdf_regions_json TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_chunks_scope ON chunks(scope);
|
|
CREATE INDEX IF NOT EXISTS idx_chunks_doc ON chunks(document_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS ingestion_jobs (
|
|
id TEXT NOT NULL PRIMARY KEY,
|
|
document_id TEXT NOT NULL,
|
|
scope TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
stage TEXT,
|
|
progress REAL NOT NULL DEFAULT 0.0,
|
|
error TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
|
|
text,
|
|
chunk_id UNINDEXED,
|
|
scope UNINDEXED,
|
|
tokenize='porter unicode61'
|
|
);
|
|
"""
|
|
)
|
|
# Lazy upgrade for databases created before project sources existed.
|
|
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:
|
|
"""Open rag.db (WAL + sqlite-vec loaded, schema created once). Raises if the extension is unavailable."""
|
|
global _schema_ready
|
|
if not RAG_AVAILABLE:
|
|
raise RuntimeError(_RAG_UNAVAILABLE_MSG)
|
|
|
|
db_path = rag_db_path()
|
|
ensure_dir(db_path.parent)
|
|
conn = sqlite3.connect(str(db_path))
|
|
conn.row_factory = sqlite3.Row
|
|
# Wait for a lock instead of erroring immediately: a figure/scan-heavy ingest can
|
|
# hold its connection across many seconds of vision calls, and a concurrent ingest
|
|
# or autoinject read would otherwise hit "database is locked".
|
|
conn.execute("PRAGMA busy_timeout = 5000")
|
|
try:
|
|
conn.enable_load_extension(True)
|
|
sqlite_vec.load(conn)
|
|
conn.enable_load_extension(False)
|
|
except Exception as exc: # noqa: BLE001
|
|
conn.close()
|
|
raise RuntimeError(_RAG_UNAVAILABLE_MSG) from exc
|
|
|
|
if not _schema_ready:
|
|
with _schema_lock:
|
|
if not _schema_ready:
|
|
try:
|
|
_ensure_schema(conn)
|
|
_schema_ready = True
|
|
except Exception:
|
|
conn.close()
|
|
raise
|
|
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). 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, "
|
|
f"chunk_id TEXT, "
|
|
f"embedding float[{int(dim)}] distance_metric=cosine)"
|
|
)
|
|
|
|
|
|
def vec_table_exists(conn: sqlite3.Connection) -> bool:
|
|
"""True if the dense ``chunks_vec`` table exists."""
|
|
row = conn.execute(
|
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='chunks_vec'"
|
|
).fetchone()
|
|
return row is not None
|
|
|
|
|
|
def _delete_document_chunks(conn, document_id: str) -> None:
|
|
"""Delete a document's chunk rows (chunks/chunks_fts/chunks_vec), keeping the
|
|
documents row. Used when reconciling a half-ingested doc to failed: retrieval
|
|
filters by scope not status, so leftover chunks would stay citable."""
|
|
chunk_ids = [
|
|
r["id"]
|
|
for r in conn.execute(
|
|
"SELECT id FROM chunks WHERE document_id=?", (document_id,)
|
|
).fetchall()
|
|
]
|
|
if not chunk_ids:
|
|
return
|
|
has_vec = vec_table_exists(conn)
|
|
for chunk_id in chunk_ids:
|
|
conn.execute("DELETE FROM chunks_fts WHERE chunk_id=?", (chunk_id,))
|
|
if has_vec:
|
|
conn.execute("DELETE FROM chunks_vec WHERE chunk_id=?", (chunk_id,))
|
|
conn.execute("DELETE FROM chunks WHERE document_id=?", (document_id,))
|
|
|
|
|
|
def reconcile_orphaned_ingestion_jobs() -> int:
|
|
"""Fail ingestion jobs/documents left mid-flight by a crash so they stop
|
|
showing as stuck "processing" and become re-ingestible. Run at startup.
|
|
No-op without RAG. Returns the number of jobs reset.
|
|
"""
|
|
if not RAG_AVAILABLE:
|
|
return 0
|
|
conn = get_connection()
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT id, document_id FROM ingestion_jobs "
|
|
"WHERE status NOT IN ('completed', 'failed')"
|
|
).fetchall()
|
|
for row in rows:
|
|
doc = conn.execute(
|
|
"SELECT status FROM documents WHERE id=?", (row["document_id"],)
|
|
).fetchone()
|
|
if doc is not None and doc["status"] == "completed":
|
|
# Worker finished indexing before the crash but didn't retire the
|
|
# job row. Mark the job completed (not failed) and keep its chunks,
|
|
# so the UI's getJob fallback after restart doesn't flag a
|
|
# searchable document as a failed ingestion.
|
|
conn.execute(
|
|
"UPDATE ingestion_jobs SET status='completed', stage='done', "
|
|
"progress=1.0, error=NULL WHERE id=?",
|
|
(row["id"],),
|
|
)
|
|
continue
|
|
conn.execute(
|
|
"UPDATE ingestion_jobs SET status='failed', stage='error', "
|
|
"error='Server restarted during ingestion' WHERE id=?",
|
|
(row["id"],),
|
|
)
|
|
conn.execute(
|
|
"UPDATE documents SET status='failed' "
|
|
"WHERE id=? AND status NOT IN ('completed', 'failed')",
|
|
(row["document_id"],),
|
|
)
|
|
# A failed or still-in-flight doc must not leave citable chunks
|
|
# (retrieval filters by scope, not status); also drops any chunks of a
|
|
# doc already 'failed' before the crash.
|
|
_delete_document_chunks(conn, row["document_id"])
|
|
conn.commit()
|
|
return len(rows)
|
|
finally:
|
|
conn.close()
|