2026-07-21 07:44:33 -05:00
|
|
|
import logging
|
2024-10-07 15:30:45 -05:00
|
|
|
import sqlite3
|
2026-09-19 09:10:36 -05:00
|
|
|
import threading
|
2026-03-26 12:54:12 -06:00
|
|
|
from typing import Any
|
2024-10-07 15:30:45 -05:00
|
|
|
|
2026-07-14 06:27:00 -05:00
|
|
|
import regex
|
2026-09-19 09:10:36 -05:00
|
|
|
from peewee import DatabaseError
|
2024-10-07 15:30:45 -05:00
|
|
|
from playhouse.sqliteq import SqliteQueueDatabase
|
|
|
|
|
|
2026-07-21 07:44:33 -05:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
2026-07-14 06:27:00 -05:00
|
|
|
REGEXP_TIMEOUT_SECONDS = 1.0
|
|
|
|
|
|
2024-10-07 15:30:45 -05:00
|
|
|
|
|
|
|
|
class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
2026-03-26 12:54:12 -06:00
|
|
|
def __init__(
|
|
|
|
|
self, *args: Any, load_vec_extension: bool = False, **kwargs: Any
|
|
|
|
|
) -> None:
|
2024-10-07 15:30:45 -05:00
|
|
|
self.load_vec_extension: bool = load_vec_extension
|
|
|
|
|
# no extension necessary, sqlite will load correctly for each platform
|
|
|
|
|
self.sqlite_vec_path = "/usr/local/lib/vec0"
|
2026-09-19 09:10:36 -05:00
|
|
|
self.upsert_lock = threading.Lock()
|
2024-10-07 16:17:57 -05:00
|
|
|
super().__init__(*args, **kwargs)
|
2024-10-07 15:30:45 -05:00
|
|
|
|
2026-03-26 12:54:12 -06:00
|
|
|
def _connect(self, *args: Any, **kwargs: Any) -> sqlite3.Connection:
|
|
|
|
|
conn: sqlite3.Connection = super()._connect(*args, **kwargs) # type: ignore[misc]
|
2024-10-07 15:30:45 -05:00
|
|
|
if self.load_vec_extension:
|
|
|
|
|
self._load_vec_extension(conn)
|
2025-08-26 08:11:37 -05:00
|
|
|
|
|
|
|
|
# register REGEXP support
|
|
|
|
|
self._register_regexp(conn)
|
|
|
|
|
|
2024-10-07 15:30:45 -05:00
|
|
|
return conn
|
|
|
|
|
|
|
|
|
|
def _load_vec_extension(self, conn: sqlite3.Connection) -> None:
|
|
|
|
|
conn.enable_load_extension(True)
|
2026-07-21 07:44:33 -05:00
|
|
|
|
|
|
|
|
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)
|
2024-10-10 09:42:24 -06:00
|
|
|
|
2025-08-26 08:11:37 -05:00
|
|
|
def _register_regexp(self, conn: sqlite3.Connection) -> None:
|
2026-03-26 12:54:12 -06:00
|
|
|
def regexp(expr: str, item: str | None) -> bool:
|
2025-08-26 08:11:37 -05:00
|
|
|
if item is None:
|
|
|
|
|
return False
|
|
|
|
|
try:
|
2026-07-14 06:27:00 -05:00
|
|
|
return (
|
|
|
|
|
regex.search(expr, item, timeout=REGEXP_TIMEOUT_SECONDS) is not None
|
|
|
|
|
)
|
|
|
|
|
except (regex.error, TimeoutError):
|
2025-08-26 08:11:37 -05:00
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
conn.create_function("REGEXP", 2, regexp)
|
|
|
|
|
|
2026-09-19 09:10:36 -05:00
|
|
|
def execute_write(self, sql: str, params: Any = None) -> None:
|
|
|
|
|
"""Run a write and wait for it, so that failures are raised here.
|
|
|
|
|
|
|
|
|
|
SqliteQueueDatabase hands non-SELECT statements to a writer thread and
|
|
|
|
|
stores any exception on the cursor it returns, so callers that ignore
|
|
|
|
|
that cursor never learn the write failed.
|
|
|
|
|
"""
|
|
|
|
|
self.execute_sql(sql, params).fetchall()
|
|
|
|
|
|
|
|
|
|
def _table_exists(self, table: str) -> bool:
|
|
|
|
|
cursor = self.execute_sql(
|
|
|
|
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
|
|
|
|
(table,),
|
|
|
|
|
)
|
|
|
|
|
return cursor.fetchone() is not None
|
|
|
|
|
|
2026-07-21 07:44:33 -05:00
|
|
|
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
|
2026-09-19 09:10:36 -05:00
|
|
|
if not self._table_exists(table):
|
2026-07-21 07:44:33 -05:00
|
|
|
logger.debug("Skipping %s cleanup, table does not exist", table)
|
|
|
|
|
return
|
|
|
|
|
|
2024-10-10 09:42:24 -06:00
|
|
|
ids = ",".join(["?" for _ in event_ids])
|
2026-09-19 09:10:36 -05:00
|
|
|
|
|
|
|
|
# callers treat cleanup as best effort, so log rather than propagate
|
|
|
|
|
try:
|
|
|
|
|
self.execute_write(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
|
|
|
|
|
except DatabaseError:
|
|
|
|
|
logger.exception("Failed to delete embeddings from %s", table)
|
2026-07-21 07:44:33 -05:00
|
|
|
|
|
|
|
|
def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None:
|
|
|
|
|
self._delete_embeddings("vec_thumbnails", event_ids)
|
2024-10-10 09:42:24 -06:00
|
|
|
|
|
|
|
|
def delete_embeddings_description(self, event_ids: list[str]) -> None:
|
2026-07-21 07:44:33 -05:00
|
|
|
self._delete_embeddings("vec_descriptions", event_ids)
|
2024-10-10 15:37:43 -06:00
|
|
|
|
2026-09-19 09:10:36 -05:00
|
|
|
def _restore_vec_info_table(self, table: str) -> None:
|
|
|
|
|
"""Recreate the _info shadow table a legacy vec0 table is missing.
|
|
|
|
|
|
|
|
|
|
sqlite-vec added _info in 0.1.6 and drops it unconditionally when a
|
|
|
|
|
table is destroyed, so tables written by Frigate 0.17 and earlier fail
|
|
|
|
|
to drop. An empty stub is enough, and leaving it unseeded keeps the
|
|
|
|
|
table reading as pre-0.1.10 if the drop does not follow.
|
|
|
|
|
"""
|
|
|
|
|
if not self._table_exists(table) or self._table_exists(f"{table}_info"):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
logger.debug("Restoring the %s_info shadow table before dropping", table)
|
|
|
|
|
self.execute_write(
|
|
|
|
|
f'CREATE TABLE "{table}_info" (key TEXT PRIMARY KEY, value ANY)'
|
|
|
|
|
)
|
|
|
|
|
|
2024-10-10 15:37:43 -06:00
|
|
|
def drop_embeddings_tables(self) -> None:
|
2026-09-19 09:10:36 -05:00
|
|
|
for table in ("vec_descriptions", "vec_thumbnails"):
|
|
|
|
|
self._restore_vec_info_table(table)
|
|
|
|
|
self.execute_write(f"DROP TABLE IF EXISTS {table}")
|
2024-10-10 15:37:43 -06:00
|
|
|
|
|
|
|
|
def create_embeddings_tables(self) -> None:
|
|
|
|
|
"""Create vec0 virtual table for embeddings"""
|
2026-09-19 09:10:36 -05:00
|
|
|
self.execute_write("""
|
2024-10-10 15:37:43 -06:00
|
|
|
CREATE VIRTUAL TABLE IF NOT EXISTS vec_thumbnails USING vec0(
|
|
|
|
|
id TEXT PRIMARY KEY,
|
2024-10-11 13:11:11 -05:00
|
|
|
thumbnail_embedding FLOAT[768] distance_metric=cosine
|
2024-10-10 15:37:43 -06:00
|
|
|
);
|
|
|
|
|
""")
|
2026-09-19 09:10:36 -05:00
|
|
|
self.execute_write("""
|
2024-10-10 15:37:43 -06:00
|
|
|
CREATE VIRTUAL TABLE IF NOT EXISTS vec_descriptions USING vec0(
|
|
|
|
|
id TEXT PRIMARY KEY,
|
2024-10-11 13:11:11 -05:00
|
|
|
description_embedding FLOAT[768] distance_metric=cosine
|
2024-10-10 15:37:43 -06:00
|
|
|
);
|
|
|
|
|
""")
|
2026-09-19 09:10:36 -05:00
|
|
|
|
|
|
|
|
def upsert_embeddings(
|
|
|
|
|
self, table: str, column: str, embeddings: dict[str, bytes]
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Write embeddings for the given event ids, replacing any that exist.
|
|
|
|
|
|
|
|
|
|
vec0 implements neither REPLACE nor UPSERT, so rows that are already
|
|
|
|
|
there have to be deleted first.
|
|
|
|
|
"""
|
|
|
|
|
if not embeddings:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
event_ids = list(embeddings.keys())
|
|
|
|
|
ids = ",".join(["?" for _ in event_ids])
|
|
|
|
|
params: list[Any] = []
|
|
|
|
|
|
|
|
|
|
for event_id in event_ids:
|
|
|
|
|
params.extend((event_id, embeddings[event_id]))
|
|
|
|
|
|
|
|
|
|
values = ", ".join(["(?, ?)"] * len(event_ids))
|
|
|
|
|
|
|
|
|
|
# reindexing and live embedding run on separate threads, and each write
|
|
|
|
|
# is queued separately, so the delete and the insert have to be held
|
|
|
|
|
# together or an interleaved pair fails on the vec0 primary key
|
|
|
|
|
with self.upsert_lock:
|
|
|
|
|
self.execute_write(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
|
|
|
|
|
self.execute_write(
|
|
|
|
|
f"INSERT INTO {table}(id, {column}) VALUES {values}", params
|
|
|
|
|
)
|