* Studio: make project sources work with RAG and polish project UI
Projects had a disabled Sources tab with an Add sources placeholder.
This wires it up end to end on top of the RAG engine:
- Add a project scope to the RAG store, ingestion and retrieval
- New endpoints: POST/GET /api/rag/projects/{id}/documents
- search_knowledge_base resolves kb, project and thread scopes; an
explicit KB stays exclusive, project and thread scopes combine
- Multi-scope search: FTS uses scope IN (...), vec0 KNN runs per
scope and merges by cosine score
- Lazy ALTER TABLE adds documents.project_id on existing databases
- Deleting a project also removes its indexed sources
- Sources tab now uploads with progress chips and drag and drop
- Chats inside a project auto-enable retrieval over project sources
when the project has indexed documents (cached probe, no Docs pill
needed); external providers still never receive rag_scope
UI polish:
- Rounder project cards with folder icon chip and softer shadow
- Project header icon in a rounded chip
- Chats/Sources pills and Add sources button without borders
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: match Add sources button shadow to the chat composer in light mode
* Studio: round project switcher hover pill and pad the folder icon
* Studio: remove border from project sources box
* Studio: grey hover on project cards and menu, move search into header, widen page spacing
* Studio: shorten sources copy, white header pills with composer shadow, fixed-width search, hub-size page headings
* Studio: align project landing blocks to the composer width
* Studio: restore muted background and flat look on projects header controls
* Studio: darker grey hover on project cards in light mode
* Studio: soften project card hover grey
* Studio: keep project card menu button visible while its menu is open
* Studio: drop focus outlines and rings on buttons and clickable icons, keep input focus styles
* Studio: address review feedback on project sources
- Remove uploaded files from disk when a project is deleted, confined
to the uploads root
- 404 project uploads when the project does not exist, matching the KB
endpoint
- Guard lexical search against an empty scope list
- Re-invalidate the project sources probe after uploads and removals
settle so a chat sent mid-upload cannot cache a stale negative
- Keep keyboard focus rings: only mouse focus drops the Tailwind ring,
the browser default outline stays removed
* Studio: add a green New badge to the project Sources tab
* Studio: unify New pills, fully round with soft emerald fill and no border
* Studio: a touch more vertical padding on New pills
* Fix project RAG source edge cases for PR #6205
* Fix duplicate RAG upload cleanup for PR #6205
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: wasimysaid <wasimysdev@gmail.com>
158 lines
5.5 KiB
Python
158 lines
5.5 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,
|
|
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
|
|
);
|
|
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")
|
|
|
|
|
|
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
|