From 8f6e7e2214ccb1219495b5e81f3d326f2825f807 Mon Sep 17 00:00:00 2001 From: chloe Date: Wed, 18 Mar 2026 20:17:45 +0000 Subject: [PATCH] Added vector search --- .../Controllers/RecipeController.cs | 24 +++++++++++++++++++ ...22_InitialCreateWith768Vector.Designer.cs} | 8 +++++-- ...60318201722_InitialCreateWith768Vector.cs} | 6 +++-- .../ApplicationDbContextModelSnapshot.cs | 4 ++++ Seasoned.Backend/Models/Recipe.cs | 6 +++++ Seasoned.Backend/Seasoned.Backend.csproj | 1 + Seasoned.Backend/Services/IRecipeService.cs | 1 + Seasoned.Backend/Services/RecipeService.cs | 19 +++++++++++++++ 8 files changed, 65 insertions(+), 4 deletions(-) rename Seasoned.Backend/Migrations/{20260318044626_ChangeIconToImageUrl.Designer.cs => 20260318201722_InitialCreateWith768Vector.Designer.cs} (98%) rename Seasoned.Backend/Migrations/{20260318044626_ChangeIconToImageUrl.cs => 20260318201722_InitialCreateWith768Vector.cs} (98%) diff --git a/Seasoned.Backend/Controllers/RecipeController.cs b/Seasoned.Backend/Controllers/RecipeController.cs index 1b396dc..f6b1cda 100644 --- a/Seasoned.Backend/Controllers/RecipeController.cs +++ b/Seasoned.Backend/Controllers/RecipeController.cs @@ -6,6 +6,8 @@ using System.Security.Claims; using Seasoned.Backend.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; +using Pgvector; +using Pgvector.EntityFrameworkCore; namespace Seasoned.Backend.Controllers; @@ -55,6 +57,9 @@ public class RecipeController : ControllerBase UserId = userId }; + var fullText = $"{recipeDto.Title} {string.Join(" ", recipeDto.Ingredients)} {string.Join(" ", recipeDto.Instructions)}"; + recipe.Embedding = await _recipeService.GetEmbeddingAsync(fullText); + _context.Recipes.Add(recipe); await _context.SaveChangesAsync(); @@ -110,4 +115,23 @@ public class RecipeController : ControllerBase var result = await _recipeService.ConsultChefAsync(request.Prompt); return Ok(result); } + + [HttpGet("search")] + public async Task>> SearchRecipes([FromQuery] string query) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + + if (string.IsNullOrWhiteSpace(query)) + return await GetMyRecipes(); + + var queryVector = await _recipeService.GetEmbeddingAsync(query); + + var results = await _context.Recipes + .Where(r => r.UserId == userId) + .OrderBy(r => r.Embedding!.CosineDistance(queryVector)) + .Take(15) + .ToListAsync(); + + return Ok(results); + } } \ No newline at end of file diff --git a/Seasoned.Backend/Migrations/20260318044626_ChangeIconToImageUrl.Designer.cs b/Seasoned.Backend/Migrations/20260318201722_InitialCreateWith768Vector.Designer.cs similarity index 98% rename from Seasoned.Backend/Migrations/20260318044626_ChangeIconToImageUrl.Designer.cs rename to Seasoned.Backend/Migrations/20260318201722_InitialCreateWith768Vector.Designer.cs index 45b2344..60b95ff 100644 --- a/Seasoned.Backend/Migrations/20260318044626_ChangeIconToImageUrl.Designer.cs +++ b/Seasoned.Backend/Migrations/20260318201722_InitialCreateWith768Vector.Designer.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Pgvector; using Seasoned.Backend.Data; #nullable disable @@ -13,8 +14,8 @@ using Seasoned.Backend.Data; namespace Seasoned.Backend.Migrations { [DbContext(typeof(ApplicationDbContext))] - [Migration("20260318044626_ChangeIconToImageUrl")] - partial class ChangeIconToImageUrl + [Migration("20260318201722_InitialCreateWith768Vector")] + partial class InitialCreateWith768Vector { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -234,6 +235,9 @@ namespace Seasoned.Backend.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("Embedding") + .HasColumnType("vector(768)"); + b.Property("ImageUrl") .HasColumnType("text"); diff --git a/Seasoned.Backend/Migrations/20260318044626_ChangeIconToImageUrl.cs b/Seasoned.Backend/Migrations/20260318201722_InitialCreateWith768Vector.cs similarity index 98% rename from Seasoned.Backend/Migrations/20260318044626_ChangeIconToImageUrl.cs rename to Seasoned.Backend/Migrations/20260318201722_InitialCreateWith768Vector.cs index bcd08d2..0bb577f 100644 --- a/Seasoned.Backend/Migrations/20260318044626_ChangeIconToImageUrl.cs +++ b/Seasoned.Backend/Migrations/20260318201722_InitialCreateWith768Vector.cs @@ -2,13 +2,14 @@ using System.Collections.Generic; using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Pgvector; #nullable disable namespace Seasoned.Backend.Migrations { /// - public partial class ChangeIconToImageUrl : Migration + public partial class InitialCreateWith768Vector : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -172,7 +173,8 @@ namespace Seasoned.Backend.Migrations Ingredients = table.Column>(type: "text[]", nullable: false), Instructions = table.Column>(type: "text[]", nullable: false), CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - UserId = table.Column(type: "text", nullable: true) + UserId = table.Column(type: "text", nullable: true), + Embedding = table.Column(type: "vector(768)", nullable: true) }, constraints: table => { diff --git a/Seasoned.Backend/Migrations/ApplicationDbContextModelSnapshot.cs b/Seasoned.Backend/Migrations/ApplicationDbContextModelSnapshot.cs index 82aa59e..9f9abe7 100644 --- a/Seasoned.Backend/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Seasoned.Backend/Migrations/ApplicationDbContextModelSnapshot.cs @@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Pgvector; using Seasoned.Backend.Data; #nullable disable @@ -231,6 +232,9 @@ namespace Seasoned.Backend.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("Embedding") + .HasColumnType("vector(768)"); + b.Property("ImageUrl") .HasColumnType("text"); diff --git a/Seasoned.Backend/Models/Recipe.cs b/Seasoned.Backend/Models/Recipe.cs index 17a4b8c..62c6b42 100644 --- a/Seasoned.Backend/Models/Recipe.cs +++ b/Seasoned.Backend/Models/Recipe.cs @@ -1,3 +1,6 @@ +using Pgvector; +using System.ComponentModel.DataAnnotations.Schema; + namespace Seasoned.Backend.Models; public class Recipe { @@ -8,4 +11,7 @@ public class Recipe { public List Instructions { get; set; } = new(); public DateTime CreatedAt { get; set; } public string? UserId { get; set; } + + [Column(TypeName = "vector(768)")] + public Vector? Embedding { get; set; } } \ No newline at end of file diff --git a/Seasoned.Backend/Seasoned.Backend.csproj b/Seasoned.Backend/Seasoned.Backend.csproj index d2af8a4..69a34e1 100644 --- a/Seasoned.Backend/Seasoned.Backend.csproj +++ b/Seasoned.Backend/Seasoned.Backend.csproj @@ -13,6 +13,7 @@ + all diff --git a/Seasoned.Backend/Services/IRecipeService.cs b/Seasoned.Backend/Services/IRecipeService.cs index bc90db6..7e26260 100644 --- a/Seasoned.Backend/Services/IRecipeService.cs +++ b/Seasoned.Backend/Services/IRecipeService.cs @@ -6,4 +6,5 @@ public interface IRecipeService { Task ParseRecipeImageAsync(IFormFile image); Task ConsultChefAsync(string userPrompt); + Task GetEmbeddingAsync(string text); } \ No newline at end of file diff --git a/Seasoned.Backend/Services/RecipeService.cs b/Seasoned.Backend/Services/RecipeService.cs index 0802866..783dc18 100644 --- a/Seasoned.Backend/Services/RecipeService.cs +++ b/Seasoned.Backend/Services/RecipeService.cs @@ -5,16 +5,33 @@ using System.IO; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Configuration; +using Pgvector; namespace Seasoned.Backend.Services; public class RecipeService : IRecipeService { private readonly string _apiKey; + private readonly GoogleAI _googleAI; public RecipeService(IConfiguration config) { _apiKey = config["GEMINI_API_KEY"] ?? throw new ArgumentNullException("API Key missing"); + _googleAI = new GoogleAI(_apiKey); + } + + public async Task GetEmbeddingAsync(string text) + { + var model = _googleAI.GenerativeModel("text-embedding-004"); + + var response = await model.EmbedContent(text); + + if (response.Embedding?.Values != null) + { + return new Vector(response.Embedding.Values.ToArray()); + } + + throw new Exception("The Chef couldn't extract the meaning from that recipe text."); } public async Task ParseRecipeImageAsync(IFormFile image) @@ -75,6 +92,7 @@ public class RecipeService : IRecipeService } catch (JsonException ex) { + Console.WriteLine($"Chef's Error: JSON Parsing failed. Message: {ex.Message}"); Console.WriteLine($"Raw AI Output: {rawText}"); return new RecipeResponseDto { Title = "Parsing Error" }; } @@ -145,4 +163,5 @@ public class RecipeService : IRecipeService if (start == -1 || end == -1) return string.Empty; return rawText.Substring(start, (end - start) + 1); } + } \ No newline at end of file