mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-11 13:21:10 +03:00
Support using GenAI for embeddings / semantic search (#22323)
* Support GenAI for embeddings * Add embed API support * Add support for embedding via genai * Basic docs * undo * Fix sending images * Don't require download check * Set model * Handle emb correctly * Clarification * Cleanup * Cleanup
This commit is contained in:
@@ -28,6 +28,7 @@ from frigate.types import ModelStatusTypesEnum
|
||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed, serialize
|
||||
from frigate.util.file import get_event_thumbnail_bytes
|
||||
|
||||
from .genai_embedding import GenAIEmbedding
|
||||
from .onnx.jina_v1_embedding import JinaV1ImageEmbedding, JinaV1TextEmbedding
|
||||
from .onnx.jina_v2_embedding import JinaV2Embedding
|
||||
|
||||
@@ -73,6 +74,7 @@ class Embeddings:
|
||||
config: FrigateConfig,
|
||||
db: SqliteVecQueueDatabase,
|
||||
metrics: DataProcessorMetrics,
|
||||
genai_manager=None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.db = db
|
||||
@@ -104,7 +106,27 @@ class Embeddings:
|
||||
},
|
||||
)
|
||||
|
||||
if self.config.semantic_search.model == SemanticSearchModelEnum.jinav2:
|
||||
model_cfg = self.config.semantic_search.model
|
||||
|
||||
if not isinstance(model_cfg, SemanticSearchModelEnum):
|
||||
# GenAI provider
|
||||
embeddings_client = (
|
||||
genai_manager.embeddings_client if genai_manager else None
|
||||
)
|
||||
if not embeddings_client:
|
||||
raise ValueError(
|
||||
f"semantic_search.model is '{model_cfg}' (GenAI provider) but "
|
||||
"no embeddings client is configured. Ensure the GenAI provider "
|
||||
"has 'embeddings' in its roles."
|
||||
)
|
||||
self.embedding = GenAIEmbedding(embeddings_client)
|
||||
self.text_embedding = lambda input_data: self.embedding(
|
||||
input_data, embedding_type="text"
|
||||
)
|
||||
self.vision_embedding = lambda input_data: self.embedding(
|
||||
input_data, embedding_type="vision"
|
||||
)
|
||||
elif model_cfg == SemanticSearchModelEnum.jinav2:
|
||||
# Single JinaV2Embedding instance for both text and vision
|
||||
self.embedding = JinaV2Embedding(
|
||||
model_size=self.config.semantic_search.model_size,
|
||||
@@ -118,7 +140,8 @@ class Embeddings:
|
||||
self.vision_embedding = lambda input_data: self.embedding(
|
||||
input_data, embedding_type="vision"
|
||||
)
|
||||
else: # Default to jinav1
|
||||
else:
|
||||
# Default to jinav1
|
||||
self.text_embedding = JinaV1TextEmbedding(
|
||||
model_size=config.semantic_search.model_size,
|
||||
requestor=self.requestor,
|
||||
@@ -136,8 +159,11 @@ class Embeddings:
|
||||
self.metrics.text_embeddings_eps.value = self.text_eps.eps()
|
||||
|
||||
def get_model_definitions(self):
|
||||
# Version-specific models
|
||||
if self.config.semantic_search.model == SemanticSearchModelEnum.jinav2:
|
||||
model_cfg = self.config.semantic_search.model
|
||||
if not isinstance(model_cfg, SemanticSearchModelEnum):
|
||||
# GenAI provider: no ONNX models to download
|
||||
models = []
|
||||
elif model_cfg == SemanticSearchModelEnum.jinav2:
|
||||
models = [
|
||||
"jinaai/jina-clip-v2-tokenizer",
|
||||
"jinaai/jina-clip-v2-model_fp16.onnx"
|
||||
@@ -312,11 +338,12 @@ class Embeddings:
|
||||
# Get total count of events to process
|
||||
total_events = Event.select().count()
|
||||
|
||||
batch_size = (
|
||||
4
|
||||
if self.config.semantic_search.model == SemanticSearchModelEnum.jinav2
|
||||
else 32
|
||||
)
|
||||
if not isinstance(self.config.semantic_search.model, SemanticSearchModelEnum):
|
||||
batch_size = 1
|
||||
elif self.config.semantic_search.model == SemanticSearchModelEnum.jinav2:
|
||||
batch_size = 4
|
||||
else:
|
||||
batch_size = 32
|
||||
current_page = 1
|
||||
|
||||
totals = {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""GenAI-backed embeddings for semantic search."""
|
||||
|
||||
import io
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frigate.genai import GenAIClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EMBEDDING_DIM = 768
|
||||
|
||||
|
||||
class GenAIEmbedding:
|
||||
"""Embedding adapter that delegates to a GenAI provider's embed API.
|
||||
|
||||
Provides the same interface as JinaV2Embedding for semantic search:
|
||||
__call__(inputs, embedding_type) -> list[np.ndarray]. Output embeddings are
|
||||
normalized to 768 dimensions for Frigate's sqlite-vec schema.
|
||||
"""
|
||||
|
||||
def __init__(self, client: "GenAIClient") -> None:
|
||||
self.client = client
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
inputs: list[str] | list[bytes] | list[Image.Image],
|
||||
embedding_type: str = "text",
|
||||
) -> list[np.ndarray]:
|
||||
"""Generate embeddings for text or images.
|
||||
|
||||
Args:
|
||||
inputs: List of strings (text) or bytes/PIL images (vision).
|
||||
embedding_type: "text" or "vision".
|
||||
|
||||
Returns:
|
||||
List of 768-dim numpy float32 arrays.
|
||||
"""
|
||||
if not inputs:
|
||||
return []
|
||||
|
||||
if embedding_type == "text":
|
||||
texts = [str(x) for x in inputs]
|
||||
embeddings = self.client.embed(texts=texts)
|
||||
elif embedding_type == "vision":
|
||||
images: list[bytes] = []
|
||||
for inp in inputs:
|
||||
if isinstance(inp, bytes):
|
||||
images.append(inp)
|
||||
elif isinstance(inp, Image.Image):
|
||||
buf = io.BytesIO()
|
||||
inp.convert("RGB").save(buf, format="JPEG")
|
||||
images.append(buf.getvalue())
|
||||
else:
|
||||
logger.warning(
|
||||
"GenAIEmbedding: skipping unsupported vision input type %s",
|
||||
type(inp).__name__,
|
||||
)
|
||||
if not images:
|
||||
return []
|
||||
embeddings = self.client.embed(images=images)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid embedding_type '{embedding_type}'. Must be 'text' or 'vision'."
|
||||
)
|
||||
|
||||
result = []
|
||||
for emb in embeddings:
|
||||
arr = np.asarray(emb, dtype=np.float32)
|
||||
if arr.ndim > 1:
|
||||
# Some providers return token-level embeddings; pool to one vector.
|
||||
arr = arr.mean(axis=0)
|
||||
arr = arr.flatten()
|
||||
if arr.size != EMBEDDING_DIM:
|
||||
if arr.size > EMBEDDING_DIM:
|
||||
arr = arr[:EMBEDDING_DIM]
|
||||
else:
|
||||
arr = np.pad(
|
||||
arr,
|
||||
(0, EMBEDDING_DIM - arr.size),
|
||||
mode="constant",
|
||||
constant_values=0,
|
||||
)
|
||||
result.append(arr)
|
||||
return result
|
||||
@@ -123,8 +123,10 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
models = [Event, Recordings, ReviewSegment, Trigger]
|
||||
db.bind(models)
|
||||
|
||||
self.genai_manager = GenAIClientManager(config)
|
||||
|
||||
if config.semantic_search.enabled:
|
||||
self.embeddings = Embeddings(config, db, metrics)
|
||||
self.embeddings = Embeddings(config, db, metrics, self.genai_manager)
|
||||
|
||||
# Check if we need to re-index events
|
||||
if config.semantic_search.reindex:
|
||||
@@ -151,7 +153,6 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
self.frame_manager = SharedMemoryFrameManager()
|
||||
|
||||
self.detected_license_plates: dict[str, dict[str, Any]] = {}
|
||||
self.genai_manager = GenAIClientManager(config)
|
||||
|
||||
# model runners to share between realtime and post processors
|
||||
if self.config.lpr.enabled:
|
||||
|
||||
Reference in New Issue
Block a user