bump sqlite-vec to 0.1.9 and always clean up embeddings when events are deleted

This commit is contained in:
Josh Hawkins
2026-07-21 07:06:20 -05:00
parent 3f91ca32bb
commit 26dd8e93bc
6 changed files with 120 additions and 22 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
set -euxo pipefail
SQLITE_VEC_VERSION="0.1.3"
SQLITE_VEC_VERSION="0.1.9"
source /etc/os-release
+15 -10
View File
@@ -1538,15 +1538,18 @@ async def set_description(
event.data["description"] = new_description
event.save()
# If semantic search is enabled, update the index
if request.app.frigate_config.semantic_search.enabled:
context: EmbeddingsContext = request.app.embeddings
context: EmbeddingsContext | None = request.app.embeddings
if context is not None:
if len(new_description) > 0:
context.update_description(
event_id,
new_description,
)
# If semantic search is enabled, update the index
if request.app.frigate_config.semantic_search.enabled:
context.update_description(
event_id,
new_description,
)
else:
# embeddings are always cleaned up so they don't outlive their description
context.db.delete_embeddings_description(event_ids=[event_id])
response_message = (
@@ -1675,9 +1678,11 @@ async def delete_single_event(event_id: str, request: Request) -> dict:
event.delete_instance()
Timeline.delete().where(Timeline.source_id == event_id).execute()
# If semantic search is enabled, update the index
if request.app.frigate_config.semantic_search.enabled:
context: EmbeddingsContext = request.app.embeddings
# embeddings are always cleaned up, even when semantic search is disabled,
# so that they don't outlive their events
context: EmbeddingsContext | None = request.app.embeddings
if context is not None:
context.db.delete_embeddings_thumbnail(event_ids=[event_id])
context.db.delete_embeddings_description(event_ids=[event_id])
+1 -1
View File
@@ -270,7 +270,7 @@ class FrigateApp:
10
* len([c for c in self.config.cameras.values() if c.enabled_in_config]),
),
load_vec_extension=self.config.semantic_search.enabled,
load_vec_extension=True,
)
models = [
Event,
+35 -6
View File
@@ -1,9 +1,12 @@
import logging
import sqlite3
from typing import Any
import regex
from playhouse.sqliteq import SqliteQueueDatabase
logger = logging.getLogger(__name__)
REGEXP_TIMEOUT_SECONDS = 1.0
@@ -28,8 +31,14 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
def _load_vec_extension(self, conn: sqlite3.Connection) -> None:
conn.enable_load_extension(True)
conn.load_extension(self.sqlite_vec_path)
conn.enable_load_extension(False)
try:
conn.load_extension(self.sqlite_vec_path)
except conn.OperationalError:
logger.error("Unable to load the sqlite-vec extension")
self.load_vec_extension = False
finally:
conn.enable_load_extension(False)
def _register_regexp(self, conn: sqlite3.Connection) -> None:
def regexp(expr: str, item: str | None) -> bool:
@@ -44,13 +53,33 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
conn.create_function("REGEXP", 2, regexp)
def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None:
def _delete_embeddings(self, table: str, event_ids: list[str]) -> None:
"""Delete embeddings for the given events, if the table exists.
Embeddings outlive the events they belong to when semantic search is
disabled, so deletes are attempted regardless of the current config.
"""
if not event_ids or not self.load_vec_extension:
return
# the embeddings tables are only created once semantic search has run
cursor = self.execute_sql(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
(table,),
)
if cursor.fetchone() is None:
logger.debug("Skipping %s cleanup, table does not exist", table)
return
ids = ",".join(["?" for _ in event_ids])
self.execute_sql(f"DELETE FROM vec_thumbnails WHERE id IN ({ids})", event_ids)
self.execute_sql(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None:
self._delete_embeddings("vec_thumbnails", event_ids)
def delete_embeddings_description(self, event_ids: list[str]) -> None:
ids = ",".join(["?" for _ in event_ids])
self.execute_sql(f"DELETE FROM vec_descriptions WHERE id IN ({ids})", event_ids)
self._delete_embeddings("vec_descriptions", event_ids)
def drop_embeddings_tables(self) -> None:
self.execute_sql("""
+5 -4
View File
@@ -366,9 +366,10 @@ class EventCleanup(threading.Thread):
logger.debug(f"Deleting {len(chunk)} events from the database")
Event.delete().where(Event.id << chunk).execute()
if self.config.semantic_search.enabled:
self.db.delete_embeddings_description(event_ids=chunk)
self.db.delete_embeddings_thumbnail(event_ids=chunk)
logger.debug(f"Deleted {len(ids_to_delete)} embeddings")
# embeddings are always cleaned up, even when semantic search
# is disabled, so that they don't outlive their events
self.db.delete_embeddings_description(event_ids=chunk)
self.db.delete_embeddings_thumbnail(event_ids=chunk)
logger.debug(f"Deleted {len(chunk)} embeddings")
logger.info("Exiting event cleanup...")
@@ -0,0 +1,63 @@
"""Tests for embedding cleanup on the main Frigate database.
Embeddings are deleted whether or not semantic search is currently enabled, so
the delete path has to tolerate databases where the vec0 tables were never
created and installs where the sqlite-vec extension is unavailable.
"""
import os
import tempfile
import unittest
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
class TestDeleteEmbeddings(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
self.db = SqliteVecQueueDatabase(os.path.join(self.tmp_dir.name, "test.db"))
self.db.start()
# the extension is not available to tests, so stand in for a database
# that has it loaded and use a plain table for the deletes
self.db.load_vec_extension = True
def tearDown(self) -> None:
self.db.stop()
self.db.close()
self.tmp_dir.cleanup()
def _flush_writes(self) -> None:
# writes are queued and applied by a worker thread, and the queue is
# FIFO, so awaiting a later write means the earlier ones are done
self.db.execute_sql("PRAGMA user_version = 0").fetchall()
def _create_thumbnails_table(self) -> None:
self.db.execute_sql("CREATE TABLE vec_thumbnails (id TEXT PRIMARY KEY)")
self.db.execute_sql("INSERT INTO vec_thumbnails (id) VALUES ('a'), ('b')")
self._flush_writes()
def _thumbnail_ids(self) -> list[str]:
return [row[0] for row in self.db.execute_sql("SELECT id FROM vec_thumbnails")]
def test_delete_without_tables_does_not_raise(self) -> None:
# semantic search was never enabled, so event cleanup has nothing to do
self.db.delete_embeddings_thumbnail(event_ids=["1700000000.0-abc"])
self.db.delete_embeddings_description(event_ids=["1700000000.0-abc"])
def test_delete_removes_embeddings(self) -> None:
self._create_thumbnails_table()
self.db.delete_embeddings_thumbnail(event_ids=["a"])
self._flush_writes()
self.assertEqual(self._thumbnail_ids(), ["b"])
def test_delete_skipped_without_extension(self) -> None:
self._create_thumbnails_table()
self.db.load_vec_extension = False
self.db.delete_embeddings_thumbnail(event_ids=["a"])
self._flush_writes()
# the vec0 tables cannot be written without the extension
self.assertEqual(self._thumbnail_ids(), ["a", "b"])