Added vector search

This commit is contained in:
2026-03-18 20:17:45 +00:00
parent ac1a910bff
commit 8f6e7e2214
8 changed files with 65 additions and 4 deletions

View File

@@ -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<ActionResult<IEnumerable<Recipe>>> 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);
}
}

View File

@@ -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
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -234,6 +235,9 @@ namespace Seasoned.Backend.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Vector>("Embedding")
.HasColumnType("vector(768)");
b.Property<string>("ImageUrl")
.HasColumnType("text");

View File

@@ -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
{
/// <inheritdoc />
public partial class ChangeIconToImageUrl : Migration
public partial class InitialCreateWith768Vector : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
@@ -172,7 +173,8 @@ namespace Seasoned.Backend.Migrations
Ingredients = table.Column<List<string>>(type: "text[]", nullable: false),
Instructions = table.Column<List<string>>(type: "text[]", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UserId = table.Column<string>(type: "text", nullable: true)
UserId = table.Column<string>(type: "text", nullable: true),
Embedding = table.Column<Vector>(type: "vector(768)", nullable: true)
},
constraints: table =>
{

View File

@@ -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<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Vector>("Embedding")
.HasColumnType("vector(768)");
b.Property<string>("ImageUrl")
.HasColumnType("text");

View File

@@ -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<string> Instructions { get; set; } = new();
public DateTime CreatedAt { get; set; }
public string? UserId { get; set; }
[Column(TypeName = "vector(768)")]
public Vector? Embedding { get; set; }
}

View File

@@ -13,6 +13,7 @@
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.13" />
<PackageReference Include="Mscc.GenerativeAI" Version="2.2.8" />
<PackageReference Include="pgvector" Version="0.3.2" />
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
<PrivateAssets>all</PrivateAssets>

View File

@@ -6,4 +6,5 @@ public interface IRecipeService
{
Task<RecipeResponseDto> ParseRecipeImageAsync(IFormFile image);
Task<ChefConsultResponseDto> ConsultChefAsync(string userPrompt);
Task<Pgvector.Vector> GetEmbeddingAsync(string text);
}

View File

@@ -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<Vector> 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<RecipeResponseDto> 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);
}
}