e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
364 lines
12 KiB
Python
364 lines
12 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,
|
|
embedding_model: 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, embedding_model) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
|
(
|
|
document_id,
|
|
scope,
|
|
kb_id,
|
|
thread_id,
|
|
project_id,
|
|
filename,
|
|
sha256,
|
|
status,
|
|
stored_path,
|
|
_now(),
|
|
embedding_model,
|
|
),
|
|
)
|
|
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,
|
|
*,
|
|
embedding_model: str | None = None,
|
|
):
|
|
"""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.
|
|
``embedding_model`` drops hits from documents indexed under a different
|
|
(same-width) model, whose vectors live in another space; NULL-model legacy
|
|
documents are assumed current, matching the ingestion dedupe rule."""
|
|
if not rag_db.vec_table_exists(conn):
|
|
return []
|
|
dim = rag_db.vec_table_dim(conn)
|
|
if dim is not None and dim != len(vector):
|
|
# Embedding model switched widths and nothing re-indexed yet; the stale
|
|
# table cannot answer new-model queries (vec0 errors on the MATCH).
|
|
return []
|
|
# Over-fetch when filtering so stale-model hits don't starve the top-k.
|
|
fetch = k * 3 if embedding_model else k
|
|
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), fetch),
|
|
).fetchall()
|
|
out.extend((r["chunk_id"], 1.0 - r["distance"]) for r in rows)
|
|
if embedding_model and out:
|
|
ids = [cid for cid, _ in out]
|
|
placeholders = ",".join("?" * len(ids))
|
|
valid = {
|
|
r["id"]
|
|
for r in conn.execute(
|
|
f"SELECT c.id FROM chunks c JOIN documents d ON d.id=c.document_id "
|
|
f"WHERE c.id IN ({placeholders}) "
|
|
f"AND (d.embedding_model IS NULL OR d.embedding_model=?)",
|
|
(*ids, embedding_model),
|
|
).fetchall()
|
|
}
|
|
out = [t for t in out if t[0] in valid]
|
|
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}
|
|
|
|
|
|
def all_chunks_for_scope(conn: sqlite3.Connection, scope) -> list[dict]:
|
|
"""Every completed-document chunk for a scope, ordered document-then-index and
|
|
joined with the document filename. Backs whole-document context injection, so
|
|
it does no retrieval or embedding."""
|
|
scopes = _scopes(scope)
|
|
if not scopes:
|
|
return []
|
|
placeholders = ",".join("?" * len(scopes))
|
|
rows = conn.execute(
|
|
f"SELECT c.id, c.text, c.document_id, c.chunk_index, c.page_number, "
|
|
f"c.token_count, d.filename, d.created_at "
|
|
f"FROM chunks c JOIN documents d ON d.id=c.document_id "
|
|
f"WHERE c.scope IN ({placeholders}) AND d.status='completed' "
|
|
f"ORDER BY d.created_at, c.document_id, c.chunk_index",
|
|
list(scopes),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def scope_token_estimate(conn: sqlite3.Connection, scope) -> int:
|
|
"""Upper-bound token total for a scope's completed chunks without hydrating text.
|
|
Mirrors ``all_chunks_for_scope`` + the ``tool._row_token_count`` fallback (stored
|
|
count, else length/4), so the whole-doc budget can be checked before loading text."""
|
|
scopes = _scopes(scope)
|
|
if not scopes:
|
|
return 0
|
|
placeholders = ",".join("?" * len(scopes))
|
|
row = conn.execute(
|
|
f"SELECT COALESCE(SUM(CASE WHEN c.token_count > 0 THEN c.token_count "
|
|
f"ELSE MAX(1, length(COALESCE(c.text, '')) / 4) END), 0) AS total "
|
|
f"FROM chunks c JOIN documents d ON d.id=c.document_id "
|
|
f"WHERE c.scope IN ({placeholders}) AND d.status='completed'",
|
|
list(scopes),
|
|
).fetchone()
|
|
return int(row["total"] or 0)
|