Skip to content

Commit 36646c6

Browse files
authored
Merge pull request #50 from vektori-ai/feat/chroma-lancedb-backends
feat(storage): add ChromaDB and LanceDB vector backends
2 parents 0aad7d1 + 8a2974b commit 36646c6

8 files changed

Lines changed: 2489 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ dependencies = [
3434
postgres = ["asyncpg>=0.29", "pgvector>=0.2"]
3535
neo4j = ["neo4j>=5.14"]
3636
qdrant = ["qdrant-client>=1.7"]
37+
chroma = ["chromadb>=0.4"]
38+
lancedb = ["lancedb>=0.5", "pyarrow>=14"]
3739
milvus = ["pymilvus>=2.5.3"]
3840
anthropic = ["anthropic>=0.20", "voyageai>=0.2"]
3941
sentence-transformers = ["sentence-transformers>=2.6"]
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
"""Integration tests for ChromaBackend — uses EphemeralClient (no server needed).
2+
3+
Tests are skipped automatically when chromadb is not installed.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import pytest
9+
10+
11+
def _is_chroma_available() -> bool:
12+
try:
13+
import chromadb # noqa: F401
14+
15+
return True
16+
except ImportError:
17+
return False
18+
19+
20+
@pytest.fixture
21+
async def chroma_backend():
22+
if not _is_chroma_available():
23+
pytest.skip("chromadb not installed")
24+
25+
from vektori.storage.chroma_backend import ChromaBackend
26+
27+
# EphemeralClient (in-memory, no disk) — path=None, host=None
28+
backend = ChromaBackend(prefix="test", embedding_dim=4)
29+
await backend.initialize()
30+
yield backend
31+
await backend.delete_user("test-user")
32+
await backend.delete_user("delete-test-user")
33+
await backend.close()
34+
35+
36+
# ── Sentences ──────────────────────────────────────────────────────────────────
37+
38+
39+
async def test_upsert_and_search_sentences(chroma_backend):
40+
emb = [0.1, 0.2, 0.3, 0.4]
41+
sentences = [
42+
{
43+
"id": "cs1",
44+
"text": "I love hiking in the mountains.",
45+
"session_id": "csess1",
46+
"turn_number": 0,
47+
"sentence_index": 0,
48+
"role": "user",
49+
}
50+
]
51+
count = await chroma_backend.upsert_sentences(sentences, [emb], user_id="test-user")
52+
assert count == 1
53+
54+
results = await chroma_backend.search_sentences(emb, user_id="test-user", limit=5)
55+
assert len(results) >= 1
56+
assert results[0]["text"] == "I love hiking in the mountains."
57+
assert "distance" in results[0]
58+
59+
60+
async def test_sentence_dedup_increments_mentions(chroma_backend):
61+
emb = [0.2, 0.3, 0.4, 0.5]
62+
sent = [
63+
{
64+
"id": "cs-dedup",
65+
"text": "I enjoy running.",
66+
"session_id": "csess2",
67+
"turn_number": 1,
68+
"sentence_index": 0,
69+
"role": "user",
70+
}
71+
]
72+
await chroma_backend.upsert_sentences(sent, [emb], user_id="test-user")
73+
await chroma_backend.upsert_sentences(sent, [emb], user_id="test-user")
74+
75+
results = await chroma_backend.search_sentences(emb, user_id="test-user", limit=5)
76+
match = next((r for r in results if r["id"] == "cs-dedup"), None)
77+
assert match is not None
78+
assert match["mentions"] == 2
79+
80+
81+
async def test_find_sentence_containing(chroma_backend):
82+
emb = [0.3, 0.4, 0.5, 0.6]
83+
sent = [
84+
{
85+
"id": "cs-find",
86+
"text": "The weather is sunny today.",
87+
"session_id": "csess3",
88+
"turn_number": 0,
89+
"sentence_index": 0,
90+
"role": "user",
91+
}
92+
]
93+
await chroma_backend.upsert_sentences(sent, [emb], user_id="test-user")
94+
95+
result = await chroma_backend.find_sentence_containing("csess3", "sunny")
96+
assert result is not None
97+
assert result["id"] == "cs-find"
98+
99+
none_result = await chroma_backend.find_sentence_containing("csess3", "rainy")
100+
assert none_result is None
101+
102+
103+
# ── Facts ──────────────────────────────────────────────────────────────────────
104+
105+
106+
async def test_insert_and_search_facts(chroma_backend):
107+
emb = [0.1, 0.2, 0.3, 0.4]
108+
fact_id = await chroma_backend.insert_fact(
109+
text="User prefers Python over Java.",
110+
embedding=emb,
111+
user_id="test-user",
112+
subject="language",
113+
)
114+
assert fact_id
115+
116+
results = await chroma_backend.search_facts(emb, user_id="test-user", limit=5)
117+
assert len(results) >= 1
118+
match = next((r for r in results if r["id"] == fact_id), None)
119+
assert match is not None
120+
assert match["text"] == "User prefers Python over Java."
121+
assert match["subject"] == "language"
122+
assert "distance" in match
123+
124+
125+
async def test_deactivate_fact_with_supersession(chroma_backend):
126+
emb = [0.2, 0.3, 0.4, 0.5]
127+
old_id = await chroma_backend.insert_fact(
128+
text="User lives in NYC.", embedding=emb, user_id="test-user"
129+
)
130+
new_id = await chroma_backend.insert_fact(
131+
text="User lives in SF.", embedding=emb, user_id="test-user"
132+
)
133+
await chroma_backend.deactivate_fact(old_id, superseded_by=new_id)
134+
135+
active = await chroma_backend.get_active_facts("test-user")
136+
ids = [f["id"] for f in active]
137+
assert old_id not in ids
138+
assert new_id in ids
139+
140+
141+
async def test_increment_fact_mentions(chroma_backend):
142+
emb = [0.3, 0.4, 0.5, 0.6]
143+
fact_id = await chroma_backend.insert_fact(
144+
text="User likes tea.", embedding=emb, user_id="test-user"
145+
)
146+
await chroma_backend.increment_fact_mentions(fact_id)
147+
await chroma_backend.increment_fact_mentions(fact_id)
148+
149+
active = await chroma_backend.get_active_facts("test-user")
150+
match = next((f for f in active if f["id"] == fact_id), None)
151+
assert match is not None
152+
assert match["mentions"] == 3
153+
154+
155+
async def test_find_fact_by_text(chroma_backend):
156+
emb = [0.4, 0.5, 0.6, 0.7]
157+
await chroma_backend.insert_fact(text="User is vegetarian.", embedding=emb, user_id="test-user")
158+
found = await chroma_backend.find_fact_by_text("test-user", "vegetarian")
159+
assert found is not None
160+
assert "vegetarian" in found["text"]
161+
162+
163+
# ── Episodes ──────────────────────────────────────────────────────────────────
164+
165+
166+
async def test_insert_and_search_episodes(chroma_backend):
167+
emb = [0.1, 0.2, 0.3, 0.4]
168+
ep_id = await chroma_backend.insert_episode(
169+
text="User discussed travel plans.",
170+
embedding=emb,
171+
user_id="test-user",
172+
session_id="csess4",
173+
)
174+
assert ep_id
175+
176+
# Idempotent insert
177+
ep_id2 = await chroma_backend.insert_episode(
178+
text="User discussed travel plans.",
179+
embedding=emb,
180+
user_id="test-user",
181+
session_id="csess4",
182+
)
183+
assert ep_id == ep_id2
184+
185+
results = await chroma_backend.search_episodes(emb, user_id="test-user", limit=5)
186+
assert any(r["id"] == ep_id for r in results)
187+
188+
189+
async def test_episode_fact_link(chroma_backend):
190+
emb = [0.2, 0.3, 0.4, 0.5]
191+
ep_id = await chroma_backend.insert_episode(
192+
text="User likes outdoor activities.",
193+
embedding=emb,
194+
user_id="test-user",
195+
)
196+
fact_id = await chroma_backend.insert_fact(
197+
text="User goes hiking weekly.", embedding=emb, user_id="test-user"
198+
)
199+
await chroma_backend.insert_episode_fact(ep_id, fact_id)
200+
201+
episodes = await chroma_backend.get_episodes_for_facts([fact_id])
202+
assert any(e["id"] == ep_id for e in episodes)
203+
204+
205+
# ── Join tables ────────────────────────────────────────────────────────────────
206+
207+
208+
async def test_fact_source_sentences(chroma_backend):
209+
emb = [0.1, 0.2, 0.3, 0.4]
210+
sent = [
211+
{
212+
"id": "cs-src1",
213+
"text": "I go to the gym every morning.",
214+
"session_id": "csess5",
215+
"turn_number": 0,
216+
"sentence_index": 0,
217+
"role": "user",
218+
}
219+
]
220+
await chroma_backend.upsert_sentences(sent, [emb], user_id="test-user")
221+
fact_id = await chroma_backend.insert_fact(
222+
text="User exercises daily.", embedding=emb, user_id="test-user"
223+
)
224+
await chroma_backend.insert_fact_source(fact_id, "cs-src1")
225+
226+
sources = await chroma_backend.get_source_sentences([fact_id])
227+
assert "cs-src1" in sources
228+
229+
230+
# ── Sessions ───────────────────────────────────────────────────────────────────
231+
232+
233+
async def test_upsert_and_get_session(chroma_backend):
234+
await chroma_backend.upsert_session(
235+
session_id="csess-get",
236+
user_id="test-user",
237+
metadata={"source": "test"},
238+
)
239+
session = await chroma_backend.get_session("csess-get", "test-user")
240+
assert session is not None
241+
assert session["user_id"] == "test-user"
242+
assert session["sentences"] == []
243+
244+
# Wrong user returns None
245+
assert await chroma_backend.get_session("csess-get", "other-user") is None
246+
247+
248+
async def test_count_sessions(chroma_backend):
249+
before = await chroma_backend.count_sessions("test-user")
250+
await chroma_backend.upsert_session("csess-cnt1", "test-user")
251+
await chroma_backend.upsert_session("csess-cnt2", "test-user")
252+
after = await chroma_backend.count_sessions("test-user")
253+
assert after == before + 2
254+
255+
256+
# ── GDPR ───────────────────────────────────────────────────────────────────────
257+
258+
259+
async def test_delete_user(chroma_backend):
260+
emb = [0.1, 0.2, 0.3, 0.4]
261+
await chroma_backend.upsert_session("csess-del", "delete-test-user")
262+
await chroma_backend.insert_fact(text="Delete me.", embedding=emb, user_id="delete-test-user")
263+
deleted = await chroma_backend.delete_user("delete-test-user")
264+
assert deleted >= 2
265+
266+
active = await chroma_backend.get_active_facts("delete-test-user")
267+
assert active == []

0 commit comments

Comments
 (0)