Skip to content

Commit 277de2b

Browse files
committed
feat(db): add SQLite fallback for embedding similarity search
Implement cosine similarity computation in Python for SQLite databases, enabling vector search functionality that was previously unsupported. This change allows users on SQLite to perform embedding searches with proper scoring and sorting, improving compatibility across database types.
1 parent ac47913 commit 277de2b

3 files changed

Lines changed: 79 additions & 16 deletions

File tree

backend/modules/knowledge/crud.py

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from sqlalchemy import select, delete
33
from typing import List, Optional, Dict, Any
44
from uuid import UUID
5+
import math
6+
import json
57

68
from backend.core.database import normalize_uuid, is_sqlite
79
from .models import KnowledgeBase, Document, Paragraph, Embedding
@@ -204,11 +206,77 @@ async def search_embeddings_by_similarity(
204206
db_kb_id = normalize_uuid(knowledge_base_id) if knowledge_base_id else None
205207
db_app_id = normalize_uuid(application_id) if application_id else None
206208

207-
bind = db.get_bind()
208-
dialect_name = getattr(bind.dialect, "name", None) if bind is not None else None
209-
210-
if is_sqlite or dialect_name == "sqlite":
211-
return []
209+
if is_sqlite:
210+
# SQLite fallback: fetch candidate vectors and compute cosine in Python
211+
query = """
212+
SELECT
213+
e.id as embedding_id,
214+
e.vector as embedding_vector,
215+
e.paragraph_id,
216+
p.content as paragraph_content,
217+
p.document_id,
218+
d.title as document_title,
219+
d.knowledge_base_id
220+
FROM embeddings e
221+
JOIN paragraphs p ON e.paragraph_id = p.id
222+
JOIN documents d ON p.document_id = d.id
223+
"""
224+
225+
params = {}
226+
where_conditions = []
227+
if db_kb_id:
228+
where_conditions.append("d.knowledge_base_id = :kb_id")
229+
params["kb_id"] = db_kb_id
230+
if db_app_id:
231+
where_conditions.append("d.application_id = :app_id")
232+
params["app_id"] = db_app_id
233+
234+
if where_conditions:
235+
query += " WHERE " + " AND ".join(where_conditions)
236+
237+
result = await db.execute(text(query), params)
238+
rows = result.fetchall()
239+
240+
def _coerce_vector(raw_value):
241+
if raw_value is None:
242+
return []
243+
if isinstance(raw_value, (bytes, bytearray, memoryview)):
244+
raw_value = raw_value.decode("utf-8")
245+
if isinstance(raw_value, str):
246+
try:
247+
raw_value = json.loads(raw_value)
248+
except json.JSONDecodeError:
249+
raw_value = []
250+
return [float(x) for x in raw_value]
251+
252+
def cosine_similarity(vec_a, vec_b):
253+
dot = sum(a * b for a, b in zip(vec_a, vec_b))
254+
norm_a = math.sqrt(sum(a * a for a in vec_a))
255+
norm_b = math.sqrt(sum(b * b for b in vec_b))
256+
if not norm_a or not norm_b:
257+
return 0.0
258+
return dot / (norm_a * norm_b)
259+
260+
scored_rows = []
261+
for row in rows:
262+
stored_vector = _coerce_vector(row.embedding_vector)
263+
score = cosine_similarity(stored_vector, query_vector)
264+
if threshold is None or score >= threshold:
265+
scored_rows.append(
266+
{
267+
"embedding_id": row.embedding_id,
268+
"paragraph_id": row.paragraph_id,
269+
"document_id": row.document_id,
270+
"knowledge_base_id": row.knowledge_base_id,
271+
"paragraph_content": row.paragraph_content,
272+
"document_title": row.document_title,
273+
"similarity_score": float(score),
274+
"embedding_vector": stored_vector,
275+
}
276+
)
277+
278+
scored_rows.sort(key=lambda r: r["similarity_score"], reverse=True)
279+
return scored_rows[:limit]
212280

213281
# PostgreSQL path with pgvector
214282
query = """
@@ -289,9 +357,6 @@ async def search_paragraphs_by_text(
289357
# Generate embedding for query text
290358
query_vector = await encode_text(query_text)
291359

292-
if is_sqlite:
293-
return []
294-
295360
# Search for similar embeddings
296361
return await search_embeddings_by_similarity(
297362
db, query_vector, limit, knowledge_base_id, application_id

ragify.db

0 Bytes
Binary file not shown.

tests/test_crud.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -832,20 +832,19 @@ async def test_get_embeddings_stats_filtered(self, db_session):
832832
@pytest.mark.asyncio
833833
async def test_search_embeddings_by_similarity_sqlite_compatibility(self, db_session):
834834
"""Test search embeddings by similarity (graceful SQLite compatibility)"""
835-
# Since we're using SQLite, pgvector operations won't work
836-
# This test verifies the function handles SQLite gracefully and returns empty results
835+
# Ensure SQLite fallback returns cosine-sorted matches (not empty)
837836
query_vector = [0.1, 0.2, 0.3] * 128
838837

839-
# This should now handle SQLite gracefully and return empty results
840838
results = await search_embeddings_by_similarity(
841839
db=db_session,
842840
query_vector=query_vector,
843841
limit=5
844842
)
845843

846-
# Should return empty list for SQLite compatibility
847844
assert isinstance(results, list)
848-
assert len(results) == 0
845+
assert len(results) > 0
846+
assert "similarity_score" in results[0]
847+
assert all("paragraph_content" in row for row in results)
849848

850849
@pytest.mark.asyncio
851850
async def test_search_paragraphs_by_text(self, db_session):
@@ -864,14 +863,13 @@ async def test_search_paragraphs_by_text(self, db_session):
864863
with patch('backend.modules.rag.embedding.encode_text') as mock_encode:
865864
mock_encode.return_value = vector
866865

867-
# This should now handle SQLite gracefully and return empty results
868866
results = await search_paragraphs_by_text(
869867
db=db_session,
870868
query_text="test query",
871869
limit=5
872870
)
873871

874-
# Should return empty list for SQLite compatibility
875872
assert isinstance(results, list)
876-
assert len(results) == 0
873+
assert len(results) > 0
874+
assert all("similarity_score" in row for row in results)
877875
mock_encode.assert_called_once_with("test query")

0 commit comments

Comments
 (0)