Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions backend/src/find_api/core/sqlite_vec_poc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
"""
SQLite + sqlite-vec proof of concept.
"""

import sqlite3
import struct

EMBEDDING_DIM = 768


def create_connection(db_path=":memory:"):
conn = sqlite3.connect(str(db_path))
conn.enable_load_extension(True)

try:
import sqlite_vec
except ModuleNotFoundError as exc:
raise RuntimeError(
"sqlite-vec is required for this desktop-runtime proof of concept. "
"Install it manually with `pip install sqlite-vec` before running "
"the sqlite_vec_poc tests."
) from exc

sqlite_vec.load(conn)
return conn
Comment on lines +11 to +25

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check if the file exists and view its content
cat -n backend/src/find_api/core/sqlite_vec_poc.py

Repository: Abhash-Chakraborty/Find

Length of output: 5602


🏁 Script executed:

# Search for tests related to this POC
fd -t f "test.*sqlite" . --type f
fd -t f ".*test.*" backend/ --type f | grep -i sqlite

Repository: Abhash-Chakraborty/Find

Length of output: 143


🏁 Script executed:

# Check usage of create_connection across the codebase
rg "create_connection" --type py -B 2 -A 2

Repository: Abhash-Chakraborty/Find

Length of output: 774


🏁 Script executed:

# Check the test file to see if create_connection is tested
cat -n backend/tests/test_sqlite_vec_poc.py

Repository: Abhash-Chakraborty/Find

Length of output: 2909


🏁 Script executed:

# Verify if sqlite_vec is actually a dependency by checking requirements/setup
fd -t f "requirements" backend/ --type f
fd -t f "setup.py" . --type f
fd -t f "pyproject.toml" . --type f

Repository: Abhash-Chakraborty/Find

Length of output: 92


🏁 Script executed:

# Check if there are any other places where conn.enable_load_extension is used
rg "enable_load_extension" --type py -B 2 -A 2

Repository: Abhash-Chakraborty/Find

Length of output: 418


🏁 Script executed:

# Check if there's any cleanup logic in SQLiteVecPOC class or if it handles connection closure
rg "close\(\)" backend/src/find_api/core/sqlite_vec_poc.py -B 2 -A 2
rg "def __del__" backend/src/find_api/core/sqlite_vec_poc.py -B 2 -A 5

Repository: Abhash-Chakraborty/Find

Length of output: 49


🏁 Script executed:

# Check pyproject.toml to see if sqlite_vec is a dependency
cat -n backend/pyproject.toml | head -80

Repository: Abhash-Chakraborty/Find

Length of output: 2357


🏁 Script executed:

# Verify best practices for sqlite3 connection error handling
web_search
python sqlite3 connection cleanup best practices error handling

Repository: Abhash-Chakraborty/Find

Length of output: 206


🌐 Web query:

sqlite3 python connection cleanup best practices exception handling

💡 Result:

When managing SQLite connections in Python, it is critical to distinguish between transaction management and resource cleanup. The built-in context manager for sqlite3 connections handles only transactions, not the closing of the database connection itself [1][2][3][4]. Best Practices for Cleanup and Exception Handling: 1. Use contextlib.closing for Automatic Cleanup: Since the standard sqlite3 connection context manager does not call.close, use contextlib.closing to ensure the connection is closed automatically when the block exits, regardless of whether an exception occurs [5][6][7][8]. import sqlite3 from contextlib import closing with closing(sqlite3.connect("database.db")) as conn: with conn: # Handles transaction commit/rollback conn.execute("INSERT INTO table_name VALUES (?)", (data,)) 2. Manual Cleanup with try/finally: If you do not use contextlib.closing, you must explicitly call.close in a finally block to guarantee the connection is released, even if an error occurs [9]. conn = sqlite3.connect("database.db") try: with conn: conn.execute("INSERT INTO table_name VALUES (?)", (data,)) except sqlite3.Error as e: print(f"Database error: {e}") finally: conn.close 3. Understanding Transaction Management: The context manager used directly on a connection object (e.g., with conn:) automatically commits if the block finishes successfully and rolls back if an exception is raised [1][2][4]. It is intended for managing transaction boundaries, not for closing the connection [7]. 4. Key Considerations: - Do not rely on garbage collection: Explicitly closing connections prevents resource leaks and file locking issues [9][8]. - Catch specific exceptions: Use sqlite3.Error or its subclasses (e.g., sqlite3.OperationalError, sqlite3.IntegrityError) to handle database-related issues specifically [1][2][9]. - Keep connections scoped: Open the connection as late as possible and close it as soon as you are done to minimize the time the database file is locked [9][10].

Citations:


🏁 Script executed:

# Check if sqlite_vec.load actually needs the extension to remain enabled
# Look for any documentation or pattern in the codebase about extension loading
rg "sqlite_vec" --type py -B 3 -A 3

Repository: Abhash-Chakraborty/Find

Length of output: 4569


🏁 Script executed:

# Check if there are any other POC files or similar patterns in the codebase
fd -t f "poc\|proof" --type f

Repository: Abhash-Chakraborty/Find

Length of output: 49


🏁 Script executed:

# Check if sqlite_vec.load needs the extension to stay enabled after loading
# Look for any documentation or check how sqlite-vec typically works
web_search
sqlite-vec python extension loading best practices enable_load_extension

Repository: Abhash-Chakraborty/Find

Length of output: 167


🏁 Script executed:

# Verify the actual impact of the resource leak by examining test more carefully
# and checking if there are any __del__ or context manager implementations
rg "__enter__|__exit__|__del__" backend/src/find_api/core/sqlite_vec_poc.py -B 2 -A 5

Repository: Abhash-Chakraborty/Find

Length of output: 49


Close connection on missing sqlite_vec import and disable extension loading after load.

On ModuleNotFoundError, the opened DB connection is leaked (line 12). Additionally, extension loading should be disabled immediately after sqlite_vec.load(conn) to minimize attack surface.

Suggested patch
 def create_connection(db_path=":memory:"):
     conn = sqlite3.connect(str(db_path))
     conn.enable_load_extension(True)

     try:
         import sqlite_vec
     except ModuleNotFoundError as exc:
+        conn.close()
         raise RuntimeError(
             "sqlite-vec is required for this desktop-runtime proof of concept. "
             "Install it manually with `pip install sqlite-vec` before running "
             "the sqlite_vec_poc tests."
         ) from exc

     sqlite_vec.load(conn)
+    conn.enable_load_extension(False)
     return conn
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/find_api/core/sqlite_vec_poc.py` around lines 11 - 25, In the
create_connection function, close the database connection before raising the
RuntimeError when ModuleNotFoundError is caught to prevent resource leaks.
Additionally, immediately after calling sqlite_vec.load(conn), disable extension
loading by calling conn.enable_load_extension(False) to minimize the attack
surface and follow the principle of least privilege.



def create_schema(conn, embedding_dim: int):
conn.execute(
"""
CREATE TABLE IF NOT EXISTS media (
id INTEGER PRIMARY KEY,
filename TEXT NOT NULL,
status TEXT NOT NULL
)
"""
)

conn.execute(
f"""
CREATE VIRTUAL TABLE IF NOT EXISTS media_vectors
USING vec0(
media_id INTEGER PRIMARY KEY,
embedding FLOAT[{embedding_dim}]
)
"""
)

conn.commit()


def insert_vector(
conn,
media_id: int,
embedding: list[float],
):
blob = struct.pack(f"{len(embedding)}f", *embedding)
Comment on lines +52 to +57

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

wc -l backend/src/find_api/core/sqlite_vec_poc.py

Repository: Abhash-Chakraborty/Find

Length of output: 115


🏁 Script executed:

cat -n backend/src/find_api/core/sqlite_vec_poc.py

Repository: Abhash-Chakraborty/Find

Length of output: 5602


Enforce embedding dimension contract at write/search boundaries (lines 57, 97-100, 123-126).

Current vector functions (insert_vector, search_vectors, search_media) accept any embedding length and pack it without validating against EMBEDDING_DIM = 768, allowing silent dimension mismatches that only fail later with cryptic sqlite-vec errors. Add explicit dimension validation before struct.pack() for deterministic errors and contract safety.

Suggested patch
 EMBEDDING_DIM = 768

+def _validate_embedding_dim(embedding: list[float], expected_dim: int = EMBEDDING_DIM) -> None:
+    if len(embedding) != expected_dim:
+        raise ValueError(
+            f"embedding must have exactly {expected_dim} dimensions, got {len(embedding)}"
+        )
+
@@
 def insert_vector(
@@
 ):
+    _validate_embedding_dim(embedding)
     blob = struct.pack(f"{len(embedding)}f", *embedding)
@@
 def search_vectors(
@@
 ):
+    _validate_embedding_dim(query_embedding)
     blob = struct.pack(
@@
 def search_media(
@@
 ):
+    _validate_embedding_dim(query_embedding)
     blob = struct.pack(

This aligns with the guideline: "Keep EMBEDDING_DIM aligned with the configured CLIP/SigLIP model and pgvector columns."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def insert_vector(
conn,
media_id: int,
embedding: list[float],
):
blob = struct.pack(f"{len(embedding)}f", *embedding)
def insert_vector(
conn,
media_id: int,
embedding: list[float],
):
_validate_embedding_dim(embedding)
blob = struct.pack(f"{len(embedding)}f", *embedding)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/find_api/core/sqlite_vec_poc.py` around lines 52 - 57, Add
explicit embedding dimension validation to enforce the EMBEDDING_DIM = 768
contract in three locations: before the struct.pack() call in insert_vector, and
before any embedding processing in search_vectors and search_media. For each
function, verify that the length of the embedding parameter matches
EMBEDDING_DIM and raise a clear ValueError if it does not, preventing silent
dimension mismatches from causing cryptic sqlite-vec errors downstream.

Source: Coding guidelines


conn.execute(
"""
INSERT INTO media_vectors(media_id, embedding)
VALUES (?, ?)
""",
(media_id, blob),
)

conn.commit()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "sqlite_vec_poc.py" | head -5

Repository: Abhash-Chakraborty/Find

Length of output: 113


🏁 Script executed:

wc -l backend/src/find_api/core/sqlite_vec_poc.py

Repository: Abhash-Chakraborty/Find

Length of output: 115


🏁 Script executed:

cat -n backend/src/find_api/core/sqlite_vec_poc.py

Repository: Abhash-Chakraborty/Find

Length of output: 5602


Make media+vector insertion atomic to prevent partial writes.

SQLiteVecPOC.insert_media() (lines 156-172) calls insert_media() and insert_vector() sequentially. Both helper functions perform independent commits (lines 83 and 67 respectively). If insert_vector() fails after insert_media() commits, the database is left with an orphaned media record without a corresponding vector.

Wrap both operations in a transaction context to ensure atomicity:

Suggested patch
 def insert_media(
     self,
     media_id,
     filename,
     embedding,
 ):
-    insert_media(
-        self.conn,
-        media_id,
-        filename,
-    )
-
-    insert_vector(
-        self.conn,
-        media_id,
-        embedding,
-    )
+    with self.conn:
+        insert_media(
+            self.conn,
+            media_id,
+            filename,
+        )
+        insert_vector(
+            self.conn,
+            media_id,
+            embedding,
+        )

Also remove conn.commit() calls from the helper functions at lines 67 and 83.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/find_api/core/sqlite_vec_poc.py` at line 67, The insert_media()
method creates a race condition where orphaned media records can be created if
insert_vector() fails after insert_media() commits. Remove the independent
conn.commit() calls from both helper functions (the one at line 67 in the vector
insertion helper and the one at line 83 in the media insertion helper). Then
wrap both the insert_media() and insert_vector() calls within the main
insert_media() method in a single transaction context that commits once after
both operations complete successfully, ensuring atomicity.



def insert_media(
conn,
media_id: int,
filename: str,
status: str = "indexed",
):
conn.execute(
"""
INSERT INTO media(id, filename, status)
VALUES (?, ?, ?)
""",
(media_id, filename, status),
)
conn.commit()


def count_vectors(conn) -> int:
row = conn.execute("SELECT COUNT(*) FROM media_vectors").fetchone()

return row[0]


def search_vectors(
conn,
query_embedding: list[float],
limit: int = 10,
):
blob = struct.pack(
f"{len(query_embedding)}f",
*query_embedding,
)

rows = conn.execute(
"""
SELECT
media_id,
distance
FROM media_vectors
WHERE embedding MATCH ?
ORDER BY distance
LIMIT ?
""",
(blob, limit),
).fetchall()

return rows


def search_media(
conn,
query_embedding,
limit=10,
):
blob = struct.pack(
f"{len(query_embedding)}f",
*query_embedding,
)

rows = conn.execute(
"""
SELECT
m.id,
m.filename,
v.distance
FROM media_vectors v
JOIN media m
ON m.id = v.media_id
WHERE embedding MATCH ?
AND k = ?
""",
(blob, limit),
).fetchall()

return rows


class SQLiteVecPOC:
def __init__(self, db_path=":memory:"):
self.conn = create_connection(db_path)

def create_schema(self):
create_schema(
self.conn,
EMBEDDING_DIM,
)

def insert_media(
self,
media_id,
filename,
embedding,
):
insert_media(
self.conn,
media_id,
filename,
)

insert_vector(
self.conn,
media_id,
embedding,
)

def search(
self,
embedding,
limit=10,
):
rows = search_media(
self.conn,
embedding,
limit,
)

return [
{
"id": row[0],
"filename": row[1],
"distance": row[2],
}
for row in rows
]

def gallery_query(self):
rows = self.conn.execute(
"""
SELECT
id,
filename,
status
FROM media
ORDER BY id
"""
).fetchall()

return [
{
"id": row[0],
"filename": row[1],
"status": row[2],
}
for row in rows
]
103 changes: 103 additions & 0 deletions backend/tests/test_sqlite_vec_poc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import importlib.util

import pytest

from find_api.core.sqlite_vec_poc import (
EMBEDDING_DIM,
SQLiteVecPOC,
)

SQLITE_VEC_AVAILABLE = importlib.util.find_spec("sqlite_vec") is not None


@pytest.fixture
def sqlite_vec_available():
if not SQLITE_VEC_AVAILABLE:
pytest.skip("sqlite-vec is optional and not installed")


def test_missing_sqlite_vec_dependency_message(tmp_path):
if SQLITE_VEC_AVAILABLE:
pytest.skip("sqlite-vec is installed")

db_file = tmp_path / "sqlite_vec.db"

with pytest.raises(RuntimeError, match="sqlite-vec is required"):
SQLiteVecPOC(db_file)


def test_schema_creation(tmp_path, sqlite_vec_available):
db_file = tmp_path / "sqlite_vec.db"

poc = SQLiteVecPOC(db_file)
poc.create_schema()

assert db_file.exists()


def test_insert_768_dimension_vector(tmp_path, sqlite_vec_available):
db_file = tmp_path / "sqlite_vec.db"

poc = SQLiteVecPOC(db_file)
poc.create_schema()

poc.insert_media(
media_id=1,
filename="cat.jpg",
embedding=[0.1] * EMBEDDING_DIM,
)

gallery = poc.gallery_query()

assert len(gallery) == 1
assert gallery[0]["filename"] == "cat.jpg"


def test_similarity_search(tmp_path, sqlite_vec_available):
db_file = tmp_path / "sqlite_vec.db"

poc = SQLiteVecPOC(db_file)
poc.create_schema()

poc.insert_media(
1,
"match.jpg",
[0.1] * EMBEDDING_DIM,
)

poc.insert_media(
2,
"far.jpg",
[0.2] * EMBEDDING_DIM,
)

results = poc.search(
[0.1] * EMBEDDING_DIM,
limit=2,
)

assert len(results) == 2
assert results[0]["id"] == 1


def test_gallery_query_shape(tmp_path, sqlite_vec_available):
db_file = tmp_path / "sqlite_vec.db"

poc = SQLiteVecPOC(db_file)
poc.create_schema()

poc.insert_media(
1,
"image.jpg",
[0.1] * EMBEDDING_DIM,
)

gallery = poc.gallery_query()

assert gallery == [
{
"id": 1,
"filename": "image.jpg",
"status": "indexed",
}
]
33 changes: 32 additions & 1 deletion docs/plans/not-started/desktop-runtime-adr.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

- **Status:** Not started
- **Date:** 2026-05-18
- **Last reviewed:** 2026-05-28
- **Last reviewed:** 2026-06-19
- **Owner:** Find maintainers
- **Related:** Issue #43, Roadmap [local-first-roadmap.md](../partial/local-first-roadmap.md), Framework choice [desktop-tauri-vs-electron-adr.md](../partial/desktop-tauri-vs-electron-adr.md)

Expand Down Expand Up @@ -74,6 +74,37 @@ The desktop runtime must remain **local-first** by default: images, embeddings,
- Query performance remains acceptable (<500ms for typical gallery + search)
- Single-file backup and export mechanisms are straightforward

**SQLite vector spike result (2026-06-19):**

A focused proof of concept now exists at `backend/src/find_api/core/sqlite_vec_poc.py` with tests in `backend/tests/test_sqlite_vec_poc.py`. It validates the basic desktop-mode shape without changing the production Docker/PostgreSQL runtime:

- creates a SQLite database and media metadata table
- loads `sqlite-vec` as an optional local extension
- creates a 768-dimensional vector table matching Find's current embedding size
- inserts media rows and vector blobs
- runs nearest-neighbor vector search
- returns a gallery-style metadata result shape

To run the spike manually:

```bash
cd backend
pip install sqlite-vec
uv run pytest tests/test_sqlite_vec_poc.py -q
```
Comment on lines +88 to +94

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Check if uv is used in this project
fd -e "uv.lock" -o -e "pyproject.toml" | head -20

Repository: Abhash-Chakraborty/Find

Length of output: 184


🏁 Script executed:

# Check for the file in question
fd "desktop-runtime-adr.md"

Repository: Abhash-Chakraborty/Find

Length of output: 113


🏁 Script executed:

# List backend directory structure
ls -la backend/ 2>/dev/null || echo "backend directory not found"

Repository: Abhash-Chakraborty/Find

Length of output: 880


🏁 Script executed:

# Read the actual file content around lines 88-94
sed -n '80,100p' docs/plans/not-started/desktop-runtime-adr.md

Repository: Abhash-Chakraborty/Find

Length of output: 729


🏁 Script executed:

# Check the backend pyproject.toml to understand the project setup
cat backend/pyproject.toml

Repository: Abhash-Chakraborty/Find

Length of output: 1883


🏁 Script executed:

# Check if there's a root pyproject.toml with uv tool configuration
cat pyproject.toml 2>/dev/null || echo "No root pyproject.toml"

Repository: Abhash-Chakraborty/Find

Length of output: 90


Use uv pip install to match the project's environment.

The repository uses uv for package management (evidenced by backend/uv.lock and [tool.uv.*] sections in backend/pyproject.toml). Using plain pip install sqlite-vec can install the package outside the uv environment, causing readers to hit the skip path even after following these steps.

♻️ Suggested edit
 cd backend
-pip install sqlite-vec
+uv pip install sqlite-vec
 uv run pytest tests/test_sqlite_vec_poc.py -q
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/plans/not-started/desktop-runtime-adr.md` around lines 88 - 94, The
spike running instructions use plain pip install instead of the project's uv
package manager, which can cause the package to be installed outside the uv
environment and lead to false skips. Replace the line pip install sqlite-vec
with uv pip install sqlite-vec to ensure the package is installed in the proper
uv environment and matches the project's package management setup.

Source: Coding guidelines


The tests skip automatically when `sqlite-vec` is not installed because this is still a desktop-runtime spike, not a default backend dependency.

Current limitations:

- It does not replace PostgreSQL + pgvector in Docker mode.
- It does not cover migrations from the existing PostgreSQL schema.
- It does not benchmark larger libraries, concurrent writes, WAL behavior, or index build cost.
- It does not yet validate Find's full hybrid search behavior, filters, clustering joins, or queue interactions.
- It keeps `sqlite-vec` out of the default dependency set until the project decides whether desktop mode should ship it.

Follow-up implementation should only happen after the spike is benchmarked against realistic local libraries and the query abstraction is designed so PostgreSQL and SQLite can coexist cleanly.

---

### 4.2 Object Storage: MinIO → Local Filesystem
Expand Down
Loading