Added vector search
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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 =>
|
||||
{
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -6,4 +6,5 @@ public interface IRecipeService
|
||||
{
|
||||
Task<RecipeResponseDto> ParseRecipeImageAsync(IFormFile image);
|
||||
Task<ChefConsultResponseDto> ConsultChefAsync(string userPrompt);
|
||||
Task<Pgvector.Vector> GetEmbeddingAsync(string text);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user