unsloth/studio/backend/core/rag/store.py
Michael Han 99704ffe47
Studio: project sources backed by RAG (#6205)
* 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>
2026-06-12 15:42:51 +02:00

294 lines
9.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
"""Unified SQLite store: relational chunks + FTS5 lexical + sqlite-vec dense.
Module-level functions each take a ``conn`` the caller opens and closes. Inserts
are incremental: ``add_chunks`` appends one document's rows without rebuilding the
scope. Scope ("kb_<id>" / "thread_<id>") is a column on every table and the vec0
partition key.
"""
from __future__ import annotations
import json
import re
import sqlite3
import struct
import uuid
from datetime import datetime, timezone
from storage import rag_db
def kb_scope(kb_id: str) -> str:
return f"kb_{kb_id}"
def thread_scope(thread_id: str) -> str:
return f"thread_{thread_id}"
def project_scope(project_id: str) -> str:
return f"project_{project_id}"
def _scopes(scope) -> list[str]:
"""Search helpers accept one scope or several (e.g. project + thread)."""
return [scope] if isinstance(scope, str) else list(scope)
def _f32(vector) -> bytes:
"""Pack a vector into float32 bytes for vec0."""
return struct.pack(f"{len(vector)}f", *(float(x) for x in vector))
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
_TOKEN = re.compile(r"\w+", re.UNICODE)
def _match_query(query: str) -> str:
"""User text -> safe FTS5 OR-of-quoted-terms query; quoting defuses FTS5
operators. "" (no tokens) means no lexical results."""
toks = _TOKEN.findall(query.lower())
return " OR ".join(f'"{t}"' for t in toks)
def create_kb(
conn: sqlite3.Connection,
*,
name: str,
description: str | None = None,
embedding_model: str | None = None,
kb_id: str | None = None,
) -> str:
kb_id = kb_id or str(uuid.uuid4())
conn.execute(
"INSERT INTO knowledge_bases(id, name, description, embedding_model, created_at) "
"VALUES(?,?,?,?,?)",
(kb_id, name, description, embedding_model, _now()),
)
conn.commit()
return kb_id
def list_kbs(conn: sqlite3.Connection) -> list[dict]:
rows = conn.execute("SELECT * FROM knowledge_bases ORDER BY created_at").fetchall()
return [dict(r) for r in rows]
def get_kb(conn: sqlite3.Connection, kb_id: str) -> dict | None:
row = conn.execute("SELECT * FROM knowledge_bases WHERE id=?", (kb_id,)).fetchone()
return dict(row) if row else None
def delete_kb(conn: sqlite3.Connection, kb_id: str) -> None:
"""Delete a knowledge base and every document (+ chunks) under it."""
scope = kb_scope(kb_id)
doc_ids = [
r["id"] for r in conn.execute("SELECT id FROM documents WHERE scope=?", (scope,)).fetchall()
]
for doc_id in doc_ids:
delete_document(conn, doc_id)
conn.execute("DELETE FROM knowledge_bases WHERE id=?", (kb_id,))
conn.commit()
def create_document(
conn: sqlite3.Connection,
*,
scope: str,
filename: str,
sha256: str,
kb_id: str | None = None,
thread_id: str | None = None,
project_id: str | None = None,
status: str = "pending",
stored_path: str | None = None,
document_id: str | None = None,
) -> str:
document_id = document_id or str(uuid.uuid4())
conn.execute(
"INSERT INTO documents(id, scope, kb_id, thread_id, project_id, filename, sha256, "
"status, stored_path, created_at) VALUES(?,?,?,?,?,?,?,?,?,?)",
(
document_id,
scope,
kb_id,
thread_id,
project_id,
filename,
sha256,
status,
stored_path,
_now(),
),
)
conn.commit()
return document_id
def set_document_status(
conn: sqlite3.Connection,
document_id: str,
status: str,
*,
num_chunks: int | None = None,
error: str | None = None,
) -> None:
conn.execute(
"UPDATE documents SET status=?, num_chunks=COALESCE(?, num_chunks), error=? WHERE id=?",
(status, num_chunks, error, document_id),
)
conn.commit()
def list_documents(conn: sqlite3.Connection, scope: str) -> list[dict]:
rows = conn.execute(
"SELECT id, scope, kb_id, thread_id, project_id, filename, sha256, status, error, "
"num_chunks, created_at "
"FROM documents WHERE scope=? ORDER BY created_at DESC",
(scope,),
).fetchall()
return [dict(r) for r in rows]
def get_document(conn: sqlite3.Connection, document_id: str) -> dict | None:
row = conn.execute("SELECT * FROM documents WHERE id=?", (document_id,)).fetchone()
return dict(row) if row else None
def document_by_hash(conn: sqlite3.Connection, scope: str, sha256: str) -> str | None:
row = conn.execute(
"SELECT id FROM documents WHERE scope=? AND sha256=? AND status!='failed' "
"ORDER BY created_at DESC LIMIT 1",
(scope, sha256),
).fetchone()
return row["id"] if row else None
def failed_documents_by_hash(conn: sqlite3.Connection, scope: str, sha256: str) -> list[dict]:
rows = conn.execute(
"SELECT id, stored_path FROM documents WHERE scope=? AND sha256=? AND status='failed'",
(scope, sha256),
).fetchall()
return [dict(r) for r in rows]
def add_chunks(
conn: sqlite3.Connection,
scope: str,
document_id: str,
chunks,
vectors,
regions = None,
) -> None:
"""Incrementally index one document's chunks into chunks + FTS5 + vec0.
``vectors`` parallels ``chunks``; optional ``regions`` (also parallel) holds
per-chunk PDF highlight rects, stored as JSON."""
if len(vectors):
rag_db.ensure_vec(conn, len(vectors[0]))
for i, (chunk, vector) in enumerate(zip(chunks, vectors)):
chunk_id = f"{document_id}:{chunk.chunk_index}"
chunk_regions = regions[i] if regions and i < len(regions) else None
regions_json = json.dumps(chunk_regions) if chunk_regions else None
conn.execute(
"INSERT OR REPLACE INTO chunks("
"id, document_id, scope, chunk_index, text, page_number, "
"source_page_index, token_count, kind, pdf_regions_json) "
"VALUES(?,?,?,?,?,?,?,?,?,?)",
(
chunk_id,
document_id,
scope,
chunk.chunk_index,
chunk.text,
chunk.page_number,
chunk.source_page_index,
chunk.token_count,
getattr(chunk, "kind", "text"),
regions_json,
),
)
conn.execute(
"INSERT INTO chunks_fts(text, chunk_id, scope) VALUES(?,?,?)",
(chunk.text, chunk_id, scope),
)
conn.execute(
"INSERT INTO chunks_vec(scope, chunk_id, embedding) VALUES(?,?,?)",
(scope, chunk_id, _f32(vector)),
)
conn.commit()
def delete_document(conn: sqlite3.Connection, document_id: str) -> None:
"""Remove a document and all its chunks (+ fts + vec rows)."""
ids = [
r["id"]
for r in conn.execute(
"SELECT id FROM chunks WHERE document_id=?", (document_id,)
).fetchall()
]
has_vec = rag_db.vec_table_exists(conn)
for chunk_id in 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,))
conn.execute("DELETE FROM documents WHERE id=?", (document_id,))
conn.commit()
def search_lexical(conn: sqlite3.Connection, scope, query: str, k: int):
"""BM25 lexical search over one scope or several. Returns
[(chunk_id, score)], higher = better."""
mq = _match_query(query)
if not mq:
return []
scopes = _scopes(scope)
if not scopes:
return []
placeholders = ",".join("?" * len(scopes))
rows = conn.execute(
f"SELECT chunk_id, bm25(chunks_fts) AS s FROM chunks_fts "
f"WHERE chunks_fts MATCH ? AND scope IN ({placeholders}) ORDER BY s LIMIT ?",
(mq, *scopes, k),
).fetchall()
# bm25() is negative (more negative = better); flip to higher-is-better.
return [(r["chunk_id"], -r["s"]) for r in rows]
def search_dense(conn: sqlite3.Connection, scope, vector, k: int):
"""Cosine KNN over vec0 for one scope or several. Returns
[(chunk_id, 1 - distance)]. vec0 KNN constrains its partition key by
equality, so multi-scope runs one query per scope and merges by score."""
if not rag_db.vec_table_exists(conn):
return []
out: list[tuple[str, float]] = []
for s in _scopes(scope):
rows = conn.execute(
"SELECT chunk_id, distance FROM chunks_vec "
"WHERE scope=? AND embedding MATCH ? ORDER BY distance LIMIT ?",
(s, _f32(vector), k),
).fetchall()
out.extend((r["chunk_id"], 1.0 - r["distance"]) for r in rows)
out.sort(key = lambda t: t[1], reverse = True)
return out[:k]
def chunks_by_id(conn: sqlite3.Connection, ids) -> dict:
"""Hydrate chunk rows (joined with document filename), keyed by id."""
if not ids:
return {}
placeholders = ",".join("?" * len(ids))
rows = conn.execute(
f"SELECT c.id, c.text, c.document_id, c.chunk_index, c.page_number, "
f"c.source_page_index, d.filename "
f"FROM chunks c JOIN documents d ON d.id=c.document_id "
f"WHERE c.id IN ({placeholders})",
list(ids),
).fetchall()
return {r["id"]: r for r in rows}