unsloth/tests/python/test_rag_vector_store.py
Roland Tannous 2093fb1608 Studio: swap RAG vector store from Qdrant to sqlite-vec
asg017/sqlite-vec is Apache-2.0 and OSI-approved. Replaces
qdrant-client (~30 MB) with a small SQLite extension loaded into a
dedicated rag.db file. Single file holds RAG vectors; bm25s indexes
and chat-side studio.db are unaffected.

- New core/rag/db.py owns the rag.db connection and sqlite-vec load.
  Extension load runs once at first open. Process-wide singleton
  protected by a lock; check_same_thread=False + WAL handles the
  FastAPI thread pool.
- core/rag/vector_store.py keeps the same public API
  (ensure_collection / upsert_chunks / search / collection_exists /
  delete_scope / delete_document) so callers in routes/rag.py,
  core/rag/ingestion.py, core/rag/tool.py, and core/rag/retrieval.py
  don't change. ensure_collection is now a no-op; collection_exists
  returns True iff the scope has at least one indexed vector.
- search uses sqlite-vec's vec_distance_cosine and converts distance
  to similarity in [0, 1] so the per-scope min_score threshold
  semantics stay identical.
- Mixed-dim scopes coexist behind WHERE scope = ? — the per-scope
  embedder resolver guarantees one embedder per scope.
- requirements/rag.txt swaps qdrant-client for sqlite-vec.
- utils/paths/storage_roots.py drops rag_vectordb_root() (the old
  qdrant directory); rag.db lives directly under rag_root().
- Rewritten tests/python/test_rag_vector_store.py for the new
  semantics (collection_exists tracks populated scopes; new tests
  for filtered search and upsert conflict resolution).

Python build requirement: connection.enable_load_extension(True)
must be available. install.sh creates the venv via uv-managed
python-build-standalone, which is compiled with
--enable-loadable-sqlite-extensions, so this works on standard
installs. core/rag/db.py raises an actionable error on the rare
custom-interpreter case.
2026-05-25 15:13:59 +04:00

157 lines
4.4 KiB
Python

"""sqlite-vec backed RAG vector store tests."""
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
STUDIO_BACKEND = REPO_ROOT / "studio" / "backend"
if str(STUDIO_BACKEND) not in sys.path:
sys.path.insert(0, str(STUDIO_BACKEND))
pytest.importorskip("sqlite_vec")
@pytest.fixture
def isolated_rag_db(tmp_path, monkeypatch):
"""Point rag.db at tmp_path and reset the cached connection so
each test gets a fresh database.
"""
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
from core.rag import db as rag_db
rag_db._reset_for_tests()
yield tmp_path
rag_db._reset_for_tests()
def test_upsert_and_search_returns_nearest_first(isolated_rag_db):
from core.rag import vector_store
scope = "kb_test"
vector_store.ensure_collection(scope, dim = 4) # no-op under sqlite-vec
vector_store.upsert_chunks(
scope,
[
{
"id": "p1",
"vector": [1.0, 0.0, 0.0, 0.0],
"payload": {"document_id": "doc1", "chunk_index": 0, "text": "first"},
},
{
"id": "p2",
"vector": [0.0, 1.0, 0.0, 0.0],
"payload": {"document_id": "doc1", "chunk_index": 1, "text": "second"},
},
],
)
results = vector_store.search(scope, [1.0, 0.0, 0.0, 0.0], top_k = 2)
assert len(results) == 2
assert results[0]["chunk_id"] == "p1"
# Cosine similarity converted to [0, 1]; closer = higher.
assert results[0]["score"] > results[1]["score"]
def test_collection_exists_tracks_populated_scope(isolated_rag_db):
from core.rag import vector_store
scope = "kb_to_delete"
assert not vector_store.collection_exists(scope)
vector_store.upsert_chunks(
scope,
[
{
"id": "sole",
"vector": [1.0, 0.0, 0.0],
"payload": {"document_id": "d", "chunk_index": 0},
}
],
)
assert vector_store.collection_exists(scope)
vector_store.delete_scope(scope)
assert not vector_store.collection_exists(scope)
def test_delete_document_removes_only_its_points(isolated_rag_db):
from core.rag import vector_store
scope = "kb_doc_del"
vector_store.upsert_chunks(
scope,
[
{
"id": "a",
"vector": [1.0, 0.0, 0.0],
"payload": {"document_id": "keep", "chunk_index": 0},
},
{
"id": "b",
"vector": [0.0, 1.0, 0.0],
"payload": {"document_id": "drop", "chunk_index": 0},
},
],
)
vector_store.delete_document(scope, "drop")
results = vector_store.search(scope, [0.0, 1.0, 0.0], top_k = 5)
doc_ids = {r["payload"]["document_id"] for r in results}
assert "drop" not in doc_ids
assert "keep" in doc_ids
def test_search_filtered_by_document_ids(isolated_rag_db):
from core.rag import vector_store
scope = "kb_filter"
vector_store.upsert_chunks(
scope,
[
{
"id": "a",
"vector": [1.0, 0.0, 0.0],
"payload": {"document_id": "alpha", "chunk_index": 0},
},
{
"id": "b",
"vector": [1.0, 0.0, 0.0],
"payload": {"document_id": "beta", "chunk_index": 0},
},
],
)
results = vector_store.search(
scope,
[1.0, 0.0, 0.0],
top_k = 5,
document_ids = ["alpha"],
)
doc_ids = {r["payload"]["document_id"] for r in results}
assert doc_ids == {"alpha"}
def test_upsert_overwrites_on_conflicting_chunk_id(isolated_rag_db):
from core.rag import vector_store
scope = "kb_overwrite"
vector_store.upsert_chunks(
scope,
[
{
"id": "same",
"vector": [1.0, 0.0, 0.0],
"payload": {"document_id": "d", "chunk_index": 0, "v": "v1"},
}
],
)
vector_store.upsert_chunks(
scope,
[
{
"id": "same",
"vector": [0.0, 1.0, 0.0],
"payload": {"document_id": "d", "chunk_index": 0, "v": "v2"},
}
],
)
results = vector_store.search(scope, [0.0, 1.0, 0.0], top_k = 5)
assert len(results) == 1
assert results[0]["payload"]["v"] == "v2"