Adds a self-contained RAG stack to Studio: knowledge bases with chunked indexing, hybrid (dense + lexical) retrieval, and an automatic first-pass context inject into chat. Embeddings run through a local llama-server GGUF backend (default unsloth/bge-small-en-v1.5-GGUF) with a sentence-transformers fallback. The chat tool loop gains a search_knowledge_base tool, a per-turn re-search cap, and source citation, layered on top of the shared ToolLoopController.
153 lines
5.2 KiB
Python
153 lines
5.2 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 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,
|
|
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
|
|
);
|
|
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'
|
|
);
|
|
"""
|
|
)
|
|
|
|
|
|
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
|
|
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 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."""
|
|
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
|