diff --git a/docker-compose.yml b/docker-compose.yml index 3be41ba..6b370d9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,8 @@ services: postgres: - image: postgres:16-alpine + # pgvector-enabled Postgres 16 (semantic search). Drop-in for postgres:16 data; + # the 'vector' extension is created by the AddEmbeddingColumn migration. + image: pgvector/pgvector:pg16 environment: POSTGRES_DB: inboxintel POSTGRES_USER: inboxintel diff --git a/src/InboxIntel.Domain/Entities/Email.cs b/src/InboxIntel.Domain/Entities/Email.cs index a63bae6..3282efc 100644 --- a/src/InboxIntel.Domain/Entities/Email.cs +++ b/src/InboxIntel.Domain/Entities/Email.cs @@ -54,6 +54,13 @@ public class Email : AuditableEntity /// public NpgsqlTypes.NpgsqlTsVector? SearchVector { get; set; } + /// + /// Semantic-search embedding (pgvector, 768-dim for nomic-embed-text). Populated by the + /// embedding backfill worker when AI is enabled; null otherwise (search falls back to + /// lexical). Nullable so the InMemory test provider and AI-off deployments work unchanged. + /// + public Pgvector.Vector? Embedding { get; set; } + public ICollection EmailLabels { get; set; } = new List(); public ICollection Attachments { get; set; } = new List(); } diff --git a/src/InboxIntel.Domain/InboxIntel.Domain.csproj b/src/InboxIntel.Domain/InboxIntel.Domain.csproj index b229bbe..955204b 100644 --- a/src/InboxIntel.Domain/InboxIntel.Domain.csproj +++ b/src/InboxIntel.Domain/InboxIntel.Domain.csproj @@ -4,7 +4,9 @@ InboxIntel.Domain - + + diff --git a/src/InboxIntel.Infrastructure/DependencyInjection.cs b/src/InboxIntel.Infrastructure/DependencyInjection.cs index 69e4338..3ee8c99 100644 --- a/src/InboxIntel.Infrastructure/DependencyInjection.cs +++ b/src/InboxIntel.Infrastructure/DependencyInjection.cs @@ -21,10 +21,11 @@ public static class DependencyInjection { public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration config) { - // EF Core / PostgreSQL + // EF Core / PostgreSQL. UseVector() enables pgvector mapping for the semantic-search + // embedding column (requires the 'vector' extension — added by the AddEmbeddingColumn migration). services.AddDbContext(opt => opt.UseNpgsql(config.GetConnectionString("Postgres"), - npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName))); + npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName).UseVector())); services.AddScoped(sp => sp.GetRequiredService()); // Options diff --git a/src/InboxIntel.Infrastructure/InboxIntel.Infrastructure.csproj b/src/InboxIntel.Infrastructure/InboxIntel.Infrastructure.csproj index 37f6a69..402a50a 100644 --- a/src/InboxIntel.Infrastructure/InboxIntel.Infrastructure.csproj +++ b/src/InboxIntel.Infrastructure/InboxIntel.Infrastructure.csproj @@ -15,6 +15,7 @@ + diff --git a/src/InboxIntel.Infrastructure/Migrations/20260702002304_AddEmbeddingColumn.Designer.cs b/src/InboxIntel.Infrastructure/Migrations/20260702002304_AddEmbeddingColumn.Designer.cs new file mode 100644 index 0000000..335c335 --- /dev/null +++ b/src/InboxIntel.Infrastructure/Migrations/20260702002304_AddEmbeddingColumn.Designer.cs @@ -0,0 +1,768 @@ +// +using System; +using InboxIntel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using NpgsqlTypes; +using Pgvector; + +#nullable disable + +namespace InboxIntel.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260702002304_AddEmbeddingColumn")] + partial class AddEmbeddingColumn + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm"); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("InboxIntel.Domain.Entities.AnalyticsAggregate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Day") + .HasColumnType("date"); + + b.Property("HourHistogramJson") + .HasColumnType("text"); + + b.Property("NewsletterCount") + .HasColumnType("integer"); + + b.Property("TotalReceived") + .HasColumnType("integer"); + + b.Property("TotalSizeBytes") + .HasColumnType("bigint"); + + b.Property("TotalUnread") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("WithAttachments") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Day") + .IsUnique(); + + b.ToTable("analytics_aggregates", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailId") + .HasColumnType("uuid"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("GmailAttachmentId") + .HasColumnType("text"); + + b.Property("MimeType") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EmailId"); + + b.HasIndex("UserId", "MimeType"); + + b.ToTable("attachments", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BodyText") + .HasColumnType("text"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Embedding") + .HasColumnType("vector(768)"); + + b.Property("GmailMessageId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("HasAttachments") + .HasColumnType("boolean"); + + b.Property("HasListUnsubscribe") + .HasColumnType("boolean"); + + b.Property("IsImportant") + .HasColumnType("boolean"); + + b.Property("IsInInbox") + .HasColumnType("boolean"); + + b.Property("IsStarred") + .HasColumnType("boolean"); + + b.Property("IsTrashed") + .HasColumnType("boolean"); + + b.Property("IsUnread") + .HasColumnType("boolean"); + + b.Property("ListUnsubscribeRaw") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SearchVector") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')", true); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SizeEstimateBytes") + .HasColumnType("bigint"); + + b.Property("Snippet") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Subject") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("SupportsOneClickUnsubscribe") + .HasColumnType("boolean"); + + b.Property("ThreadId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Embedding"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Embedding"), "hnsw"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Embedding"), new[] { "vector_cosine_ops" }); + + b.HasIndex("SearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN"); + + b.HasIndex("SenderId"); + + b.HasIndex("ThreadId"); + + b.HasIndex("UserId", "Category"); + + b.HasIndex("UserId", "GmailMessageId") + .IsUnique(); + + b.HasIndex("UserId", "IsInInbox"); + + b.HasIndex("UserId", "IsUnread"); + + b.HasIndex("UserId", "SenderId"); + + b.HasIndex("UserId", "SentAtUtc"); + + b.ToTable("emails", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b => + { + b.Property("EmailId") + .HasColumnType("uuid"); + + b.Property("LabelId") + .HasColumnType("uuid"); + + b.HasKey("EmailId", "LabelId"); + + b.HasIndex("LabelId"); + + b.ToTable("email_labels", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ColorHex") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GmailLabelId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "GmailLabelId") + .IsUnique(); + + b.ToTable("labels", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailCount") + .HasColumnType("integer"); + + b.Property("IsBulkSender") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" }); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("domains", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstMessageUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GmailThreadId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastMessageUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MessageCount") + .HasColumnType("integer"); + + b.Property("Snippet") + .HasColumnType("text"); + + b.Property("Subject") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "GmailThreadId") + .IsUnique(); + + b.ToTable("threads", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("DomainId") + .HasColumnType("uuid"); + + b.Property("EmailCount") + .HasColumnType("integer"); + + b.Property("HasUnsubscribe") + .HasColumnType("boolean"); + + b.Property("LastReceivedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalSizeBytes") + .HasColumnType("bigint"); + + b.Property("UnreadCount") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Address"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Address"), "gin"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Address"), new[] { "gin_trgm_ops" }); + + b.HasIndex("DisplayName"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("DisplayName"), "gin"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("DisplayName"), new[] { "gin_trgm_ops" }); + + b.HasIndex("DomainId"); + + b.HasIndex("UserId", "Address") + .IsUnique(); + + b.HasIndex("UserId", "EmailCount"); + + b.ToTable("senders", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.SyncState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("LastHistoryId") + .HasColumnType("text"); + + b.Property("LastSuccessfulSyncUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSyncType") + .HasColumnType("integer"); + + b.Property("MessagesProcessed") + .HasColumnType("integer"); + + b.Property("ResumePageToken") + .HasColumnType("text"); + + b.Property("StartedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TotalMessagesEstimate") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("sync_states", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Confidence") + .HasColumnType("double precision"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailCount") + .HasColumnType("integer"); + + b.Property("LastAttemptUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Method") + .HasColumnType("integer"); + + b.Property("ResultMessage") + .HasColumnType("text"); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UnsubscribeTarget") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SenderId"); + + b.HasIndex("UserId", "SenderId") + .IsUnique(); + + b.ToTable("unsubscribe_items", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessTokenExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DigestEnabled") + .HasColumnType("boolean"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("EncryptedRefreshToken") + .HasColumnType("bytea"); + + b.Property("GoogleSubjectId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastDigestSentUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastLoginUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PictureUrl") + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("GoogleSubjectId") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("H") + .HasColumnType("integer"); + + b.Property("SettingsJson") + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Visible") + .HasColumnType("boolean"); + + b.Property("W") + .HasColumnType("integer"); + + b.Property("WidgetKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X") + .HasColumnType("integer"); + + b.Property("Y") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "WidgetKey") + .IsUnique(); + + b.ToTable("widget_layouts", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b => + { + b.HasOne("InboxIntel.Domain.Entities.Email", "Email") + .WithMany("Attachments") + .HasForeignKey("EmailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Email"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b => + { + b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender") + .WithMany("Emails") + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InboxIntel.Domain.Entities.MailThread", "Thread") + .WithMany("Emails") + .HasForeignKey("ThreadId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InboxIntel.Domain.Entities.User", null) + .WithMany("Emails") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sender"); + + b.Navigation("Thread"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b => + { + b.HasOne("InboxIntel.Domain.Entities.Email", "Email") + .WithMany("EmailLabels") + .HasForeignKey("EmailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InboxIntel.Domain.Entities.Label", "Label") + .WithMany("EmailLabels") + .HasForeignKey("LabelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Email"); + + b.Navigation("Label"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b => + { + b.HasOne("InboxIntel.Domain.Entities.MailDomain", "Domain") + .WithMany("Senders") + .HasForeignKey("DomainId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Domain"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b => + { + b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender") + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sender"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b => + { + b.HasOne("InboxIntel.Domain.Entities.User", null) + .WithMany("WidgetLayouts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b => + { + b.Navigation("Attachments"); + + b.Navigation("EmailLabels"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b => + { + b.Navigation("EmailLabels"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b => + { + b.Navigation("Senders"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b => + { + b.Navigation("Emails"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b => + { + b.Navigation("Emails"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.User", b => + { + b.Navigation("Emails"); + + b.Navigation("WidgetLayouts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/InboxIntel.Infrastructure/Migrations/20260702002304_AddEmbeddingColumn.cs b/src/InboxIntel.Infrastructure/Migrations/20260702002304_AddEmbeddingColumn.cs new file mode 100644 index 0000000..99573e4 --- /dev/null +++ b/src/InboxIntel.Infrastructure/Migrations/20260702002304_AddEmbeddingColumn.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Pgvector; + +#nullable disable + +namespace InboxIntel.Infrastructure.Migrations +{ + /// + public partial class AddEmbeddingColumn : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:PostgresExtension:pg_trgm", ",,") + .Annotation("Npgsql:PostgresExtension:vector", ",,") + .OldAnnotation("Npgsql:PostgresExtension:pg_trgm", ",,"); + + migrationBuilder.AddColumn( + name: "Embedding", + table: "emails", + type: "vector(768)", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_emails_Embedding", + table: "emails", + column: "Embedding") + .Annotation("Npgsql:IndexMethod", "hnsw") + .Annotation("Npgsql:IndexOperators", new[] { "vector_cosine_ops" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_emails_Embedding", + table: "emails"); + + migrationBuilder.DropColumn( + name: "Embedding", + table: "emails"); + + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:PostgresExtension:pg_trgm", ",,") + .OldAnnotation("Npgsql:PostgresExtension:pg_trgm", ",,") + .OldAnnotation("Npgsql:PostgresExtension:vector", ",,"); + } + } +} diff --git a/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 7ad75dc..41db34c 100644 --- a/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using NpgsqlTypes; +using Pgvector; #nullable disable @@ -22,6 +23,7 @@ namespace InboxIntel.Infrastructure.Migrations .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm"); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.Entity("InboxIntel.Domain.Entities.AnalyticsAggregate", b => @@ -125,6 +127,9 @@ namespace InboxIntel.Infrastructure.Migrations b.Property("CreatedAtUtc") .HasColumnType("timestamp with time zone"); + b.Property("Embedding") + .HasColumnType("vector(768)"); + b.Property("GmailMessageId") .IsRequired() .HasMaxLength(64) @@ -194,6 +199,11 @@ namespace InboxIntel.Infrastructure.Migrations b.HasKey("Id"); + b.HasIndex("Embedding"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Embedding"), "hnsw"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Embedding"), new[] { "vector_cosine_ops" }); + b.HasIndex("SearchVector"); NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN"); diff --git a/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs b/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs index d71c65e..39b3991 100644 --- a/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs +++ b/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs @@ -85,10 +85,18 @@ public class AppDbContext : DbContext, IAppDbContext .HasMethod("gin").HasOperators("gin_trgm_ops"); modelBuilder.Entity().HasIndex(d => d.Name) .HasMethod("gin").HasOperators("gin_trgm_ops"); + + // pgvector: 768-dim embedding for semantic search, with an HNSW cosine index. + // Populated by the embedding backfill worker when AI is enabled; null otherwise. + modelBuilder.HasPostgresExtension("vector"); + modelBuilder.Entity().Property(e => e.Embedding).HasColumnType("vector(768)"); + modelBuilder.Entity().HasIndex(e => e.Embedding) + .HasMethod("hnsw").HasOperators("vector_cosine_ops"); } else { modelBuilder.Entity().Ignore(e => e.SearchVector); + modelBuilder.Entity().Ignore(e => e.Embedding); } base.OnModelCreating(modelBuilder); diff --git a/src/InboxIntel.Infrastructure/Persistence/AppDbContextFactory.cs b/src/InboxIntel.Infrastructure/Persistence/AppDbContextFactory.cs index 884f401..64b90e2 100644 --- a/src/InboxIntel.Infrastructure/Persistence/AppDbContextFactory.cs +++ b/src/InboxIntel.Infrastructure/Persistence/AppDbContextFactory.cs @@ -15,7 +15,7 @@ public class AppDbContextFactory : IDesignTimeDbContextFactory var conn = Environment.GetEnvironmentVariable("EF_CONNECTION") ?? "Host=localhost;Port=5432;Database=inboxintel;Username=inboxintel;Password=inboxintel"; var options = new DbContextOptionsBuilder() - .UseNpgsql(conn) + .UseNpgsql(conn, o => o.UseVector()) .Options; return new AppDbContext(options); }