"""Odoo model for storing vector embeddings via pgvector.""" import json import logging from odoo import api, models, fields _logger = logging.getLogger(__name__) VECTOR_DIM = 384 # all-MiniLM-L6-v2 output dimension class EncoachEmbedding(models.Model): _name = 'encoach.embedding' _description = 'Vector Embedding' _order = 'create_date desc' content_type = fields.Selection([ ('course', 'Course'), ('resource', 'Resource'), ('question', 'Question'), ('module', 'Module'), ('topic', 'Topic'), ('feedback', 'Feedback'), ('generation_log', 'Generation Log'), ('material', 'Course Material'), ], required=True, index=True) content_id = fields.Integer(required=True, index=True) content_text = fields.Text() metadata_json = fields.Text(default='{}') _content_unique = models.Constraint( 'UNIQUE(content_type, content_id)', 'Each content item can only have one embedding.', ) @api.model def _auto_init(self): res = super()._auto_init() cr = self.env.cr cr.execute("SELECT 1 FROM pg_extension WHERE extname = 'vector'") if not cr.fetchone(): try: cr.execute("CREATE EXTENSION IF NOT EXISTS vector") _logger.info("pgvector extension created") except Exception: _logger.warning( "Could not create pgvector extension — run " "'CREATE EXTENSION vector' as a superuser", exc_info=True, ) return res cr.execute(""" SELECT column_name FROM information_schema.columns WHERE table_name = 'encoach_embedding' AND column_name = 'embedding' """) if not cr.fetchone(): cr.execute( f"ALTER TABLE encoach_embedding ADD COLUMN embedding vector({VECTOR_DIM})" ) cr.execute( "CREATE INDEX IF NOT EXISTS encoach_embedding_vec_idx " "ON encoach_embedding USING ivfflat (embedding vector_cosine_ops) " "WITH (lists = 100)" ) _logger.info("Vector column and index created on encoach_embedding") return res def set_embedding(self, vector): """Store a vector embedding for this record.""" self.ensure_one() vec_str = '[' + ','.join(str(v) for v in vector) + ']' self.env.cr.execute( "UPDATE encoach_embedding SET embedding = %s WHERE id = %s", (vec_str, self.id), ) @api.model def cron_reindex(self): """Cron entry point for periodic re-indexing.""" from odoo.addons.encoach_vector.services.indexer import index_all return index_all(self.env) @api.model def similarity_search(self, query_vector, *, content_type=None, limit=10): """Find similar embeddings using cosine distance.""" vec_str = '[' + ','.join(str(v) for v in query_vector) + ']' where = "WHERE embedding IS NOT NULL" params = [vec_str, limit] if content_type: where += " AND content_type = %s" params = [vec_str, content_type, limit] query = f""" SELECT id, content_type, content_id, content_text, metadata_json, 1 - (embedding <=> %s::vector) AS similarity FROM encoach_embedding {where} ORDER BY embedding <=> %s::vector LIMIT %s """ if content_type: self.env.cr.execute(query, (vec_str, content_type, vec_str, limit)) else: self.env.cr.execute(query, (vec_str, vec_str, limit)) results = [] for row in self.env.cr.dictfetchall(): metadata = {} try: metadata = json.loads(row['metadata_json'] or '{}') except (json.JSONDecodeError, TypeError): pass results.append({ 'id': row['id'], 'content_type': row['content_type'], 'content_id': row['content_id'], 'text': row['content_text'], 'metadata': metadata, 'similarity': round(row['similarity'], 4), }) return results