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.
157 lines
4.4 KiB
Python
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"
|