From 9f54c63c53e29de873ba9528b460c4ccc593ec1d Mon Sep 17 00:00:00 2001 From: Jesse Brault Date: Sun, 28 Dec 2025 21:22:13 -0600 Subject: [PATCH] Add basic AI search. --- build.gradle | 14 +++++ .../api/BackfillRecipeEmbeddings.java | 58 +++++++++++++++++ .../api/recipe/RecipeController.java | 26 +++++++- .../api/recipe/RecipeEmbeddingEntity.java | 63 +++++++++++++++++++ .../api/recipe/RecipeEntity.java | 11 ++++ .../api/recipe/RecipeRepository.java | 30 +++++++++ .../api/recipe/RecipeService.java | 7 +++ .../api/recipe/RecipeServiceImpl.java | 26 +++++++- .../api/recipe/body/RecipeSearchBody.java | 30 +++++++++ .../api/recipe/spec/RecipeAiSearchSpec.java | 15 +++++ src/main/resources/application.properties | 3 + .../migration/V3__create_vector_extension.sql | 1 + .../migration/V4__create_recipe_embedding.sql | 7 +++ 13 files changed, 286 insertions(+), 5 deletions(-) create mode 100644 src/main/java/app/mealsmadeeasy/api/BackfillRecipeEmbeddings.java create mode 100644 src/main/java/app/mealsmadeeasy/api/recipe/RecipeEmbeddingEntity.java create mode 100644 src/main/java/app/mealsmadeeasy/api/recipe/body/RecipeSearchBody.java create mode 100644 src/main/java/app/mealsmadeeasy/api/recipe/spec/RecipeAiSearchSpec.java create mode 100644 src/main/resources/db/migration/V3__create_vector_extension.sql create mode 100644 src/main/resources/db/migration/V4__create_recipe_embedding.sql diff --git a/build.gradle b/build.gradle index 79bd272..d97cdb9 100644 --- a/build.gradle +++ b/build.gradle @@ -43,11 +43,19 @@ configurations { } } +ext { + set('springAiVersion', "1.1.2") +} + dependencies { // From Spring Initalizr implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.ai:spring-ai-advisors-vector-store' + implementation 'org.springframework.ai:spring-ai-starter-model-ollama' + implementation 'org.springframework.ai:spring-ai-starter-vector-store-pgvector' + implementation 'org.hibernate.orm:hibernate-vector:6.6.39.Final' runtimeOnly 'org.postgresql:postgresql' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' @@ -88,6 +96,12 @@ dependencies { testFixturesImplementation 'org.hamcrest:hamcrest:3.0' } +dependencyManagement { + imports { + mavenBom "org.springframework.ai:spring-ai-bom:${springAiVersion}" + } +} + tasks.register('integrationTest', Test) { description = 'Run integration tests.' group = 'verification' diff --git a/src/main/java/app/mealsmadeeasy/api/BackfillRecipeEmbeddings.java b/src/main/java/app/mealsmadeeasy/api/BackfillRecipeEmbeddings.java new file mode 100644 index 0000000..f278da3 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/BackfillRecipeEmbeddings.java @@ -0,0 +1,58 @@ +package app.mealsmadeeasy.api; + +import app.mealsmadeeasy.api.recipe.RecipeEmbeddingEntity; +import app.mealsmadeeasy.api.recipe.RecipeEntity; +import app.mealsmadeeasy.api.recipe.RecipeRepository; +import app.mealsmadeeasy.api.recipe.RecipeService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.time.OffsetDateTime; +import java.util.List; + +@Component +@ConditionalOnProperty(name = "backfill.recipe-embeddings.enabled", havingValue = "true") +public class BackfillRecipeEmbeddings implements ApplicationRunner { + + private static final Logger logger = LoggerFactory.getLogger(BackfillRecipeEmbeddings.class); + + private final RecipeRepository recipeRepository; + private final RecipeService recipeService; + private final EmbeddingModel embeddingModel; + + public BackfillRecipeEmbeddings( + RecipeRepository recipeRepository, + RecipeService recipeService, + EmbeddingModel embeddingModel + ) { + this.recipeRepository = recipeRepository; + this.recipeService = recipeService; + this.embeddingModel = embeddingModel; + } + + @Override + public void run(ApplicationArguments args) { + final List recipeEntities = this.recipeRepository.findAllByEmbeddingIsNull(); + for (final RecipeEntity recipeEntity : recipeEntities) { + logger.info("Calculating embedding for {}", recipeEntity); + final String renderedMarkdown = this.recipeService.getRenderedMarkdown(recipeEntity); + final String toEmbed = "

" + recipeEntity.getTitle() + "

" + renderedMarkdown; + final float[] embedding = this.embeddingModel.embed(toEmbed); + + final RecipeEmbeddingEntity recipeEmbedding = new RecipeEmbeddingEntity(); + recipeEmbedding.setRecipe(recipeEntity); + recipeEmbedding.setEmbedding(embedding); + recipeEmbedding.setTimestamp(OffsetDateTime.now()); + recipeEntity.setEmbedding(recipeEmbedding); + + this.recipeRepository.save(recipeEntity); + } + this.recipeRepository.flush(); + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeController.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeController.java index b10734a..9dda560 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeController.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeController.java @@ -1,10 +1,12 @@ package app.mealsmadeeasy.api.recipe; import app.mealsmadeeasy.api.image.ImageException; +import app.mealsmadeeasy.api.recipe.body.RecipeSearchBody; import app.mealsmadeeasy.api.recipe.comment.RecipeComment; import app.mealsmadeeasy.api.recipe.comment.RecipeCommentCreateBody; import app.mealsmadeeasy.api.recipe.comment.RecipeCommentService; import app.mealsmadeeasy.api.recipe.comment.RecipeCommentView; +import app.mealsmadeeasy.api.recipe.spec.RecipeAiSearchSpec; import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec; import app.mealsmadeeasy.api.recipe.star.RecipeStar; import app.mealsmadeeasy.api.recipe.star.RecipeStarService; @@ -13,6 +15,7 @@ import app.mealsmadeeasy.api.recipe.view.RecipeExceptionView; import app.mealsmadeeasy.api.recipe.view.RecipeInfoView; import app.mealsmadeeasy.api.sliceview.SliceViewService; import app.mealsmadeeasy.api.user.User; +import com.fasterxml.jackson.databind.ObjectMapper; import org.jetbrains.annotations.Nullable; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -23,6 +26,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.HashMap; +import java.util.List; import java.util.Map; @RestController @@ -33,17 +37,20 @@ public class RecipeController { private final RecipeStarService recipeStarService; private final RecipeCommentService recipeCommentService; private final SliceViewService sliceViewService; + private final ObjectMapper objectMapper; public RecipeController( RecipeService recipeService, RecipeStarService recipeStarService, RecipeCommentService recipeCommentService, - SliceViewService sliceViewService + SliceViewService sliceViewService, + ObjectMapper objectMapper ) { this.recipeService = recipeService; this.recipeStarService = recipeStarService; this.recipeCommentService = recipeCommentService; this.sliceViewService = sliceViewService; + this.objectMapper = objectMapper; } @ExceptionHandler(RecipeException.class) @@ -98,10 +105,23 @@ public class RecipeController { @GetMapping public ResponseEntity> getRecipeInfoViews( Pageable pageable, + @RequestBody(required = false) RecipeSearchBody recipeSearchBody, @AuthenticationPrincipal User user ) { - final Slice slice = this.recipeService.getInfoViewsViewableBy(pageable, user); - return ResponseEntity.ok(this.sliceViewService.getSliceView(slice)); + if (recipeSearchBody != null) { + if (recipeSearchBody.getType() == RecipeSearchBody.Type.AI_PROMPT) { + final RecipeAiSearchSpec spec = this.objectMapper.convertValue( + recipeSearchBody.getData(), RecipeAiSearchSpec.class + ); + final List results = this.recipeService.aiSearch(spec, user); + return ResponseEntity.ok(Map.of("results", results)); + } else { + throw new IllegalArgumentException("Invalid recipeSearchBody type: " + recipeSearchBody.getType()); + } + } else { + final Slice slice = this.recipeService.getInfoViewsViewableBy(pageable, user); + return ResponseEntity.ok(this.sliceViewService.getSliceView(slice)); + } } @PostMapping("/{username}/{slug}/star") diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeEmbeddingEntity.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeEmbeddingEntity.java new file mode 100644 index 0000000..bc961e8 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeEmbeddingEntity.java @@ -0,0 +1,63 @@ +package app.mealsmadeeasy.api.recipe; + +import jakarta.persistence.*; +import org.hibernate.annotations.Array; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import org.jetbrains.annotations.Nullable; + +import java.time.OffsetDateTime; + +@Entity +@Table(name = "recipe_embedding") +public class RecipeEmbeddingEntity { + + @Id + private Integer id; + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @MapsId + @JoinColumn(name = "recipe_id") + private RecipeEntity recipe; + + @JdbcTypeCode(SqlTypes.VECTOR) + @Array(length = 1024) + @Nullable + private float[] embedding; + + @Column(nullable = false) + private OffsetDateTime timestamp; + + public Integer getId() { + return this.id; + } + + public void setId(Integer id) { + this.id = id; + } + + public RecipeEntity getRecipe() { + return this.recipe; + } + + public void setRecipe(RecipeEntity recipe) { + this.recipe = recipe; + } + + public float[] getEmbedding() { + return this.embedding; + } + + public void setEmbedding(float[] embedding) { + this.embedding = embedding; + } + + public OffsetDateTime getTimestamp() { + return this.timestamp; + } + + public void setTimestamp(OffsetDateTime timestamp) { + this.timestamp = timestamp; + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeEntity.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeEntity.java index ef9ab17..564c8e6 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeEntity.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeEntity.java @@ -78,6 +78,9 @@ public final class RecipeEntity implements Recipe { @JoinColumn(name = "main_image_id") private S3ImageEntity mainImage; + @OneToOne(mappedBy = "recipe", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + private RecipeEmbeddingEntity embedding; + @Override public Integer getId() { return this.id; @@ -238,4 +241,12 @@ public final class RecipeEntity implements Recipe { this.mainImage = image; } + public RecipeEmbeddingEntity getEmbedding() { + return this.embedding; + } + + public void setEmbedding(RecipeEmbeddingEntity embedding) { + this.embedding = embedding; + } + } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeRepository.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeRepository.java index 82645b1..8ab6ad5 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeRepository.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeRepository.java @@ -41,4 +41,34 @@ public interface RecipeRepository extends JpaRepository { @Query("SELECT r FROM Recipe r WHERE r.isPublic OR r.owner = ?1 OR ?1 MEMBER OF r.viewers") Slice findAllViewableBy(UserEntity viewer, Pageable pageable); + List findAllByEmbeddingIsNull(); + + @Query( + nativeQuery = true, + value = """ + WITH distances AS (SELECT recipe_id, embedding <=> cast(?1 AS vector) AS distance FROM recipe_embedding) + SELECT r.* FROM distances d + INNER JOIN recipe r ON r.id = d.recipe_id + WHERE d.distance < ?2 AND ( + r.is_public = TRUE + OR r.owner_id = ?3 + OR exists(SELECT 1 FROM recipe_viewer v WHERE v.recipe_id = r.id AND v.viewer_id = ?3) + ) + ORDER BY d.distance; + """ + ) + List searchByEmbeddingAndViewableBy(float[] queryEmbedding, float similarity, Integer viewerId); + + @Query( + nativeQuery = true, + value = """ + WITH distances AS (SELECT recipe_id, embedding <=> cast(?1 AS vector) AS distance FROM recipe_embedding) + SELECT r.* FROM distances d + INNER JOIN recipe r ON r.id = d.recipe_id + WHERE d.distance < ?2 AND r.is_public = TRUE + ORDER BY d.distance; + """ + ) + List searchByEmbeddingAndIsPublic(float[] queryEmbedding, float similarity); + } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java index 562a074..71992a9 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java @@ -1,11 +1,13 @@ package app.mealsmadeeasy.api.recipe; import app.mealsmadeeasy.api.image.ImageException; +import app.mealsmadeeasy.api.recipe.spec.RecipeAiSearchSpec; import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec; import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec; import app.mealsmadeeasy.api.recipe.view.FullRecipeView; import app.mealsmadeeasy.api.recipe.view.RecipeInfoView; import app.mealsmadeeasy.api.user.User; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.Nullable; import org.springframework.data.domain.Pageable; @@ -35,6 +37,8 @@ public interface RecipeService { List getRecipesViewableBy(User viewer); List getRecipesOwnedBy(User owner); + List aiSearch(RecipeAiSearchSpec searchSpec, @Nullable User viewer); + Recipe update(String username, String slug, RecipeUpdateSpec spec, User modifier) throws RecipeException, ImageException; @@ -53,4 +57,7 @@ public interface RecipeService { @Contract("_, _, null -> null") @Nullable Boolean isOwner(String username, String slug, @Nullable User viewer); + @ApiStatus.Internal + String getRenderedMarkdown(RecipeEntity entity); + } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeServiceImpl.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeServiceImpl.java index 77a5dbb..0102f6d 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeServiceImpl.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeServiceImpl.java @@ -6,6 +6,7 @@ import app.mealsmadeeasy.api.image.ImageService; import app.mealsmadeeasy.api.image.S3ImageEntity; import app.mealsmadeeasy.api.image.view.ImageView; import app.mealsmadeeasy.api.markdown.MarkdownService; +import app.mealsmadeeasy.api.recipe.spec.RecipeAiSearchSpec; import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec; import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec; import app.mealsmadeeasy.api.recipe.star.RecipeStarRepository; @@ -13,8 +14,10 @@ import app.mealsmadeeasy.api.recipe.view.FullRecipeView; import app.mealsmadeeasy.api.recipe.view.RecipeInfoView; import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.UserEntity; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.Nullable; +import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.security.access.AccessDeniedException; @@ -34,17 +37,20 @@ public class RecipeServiceImpl implements RecipeService { private final RecipeStarRepository recipeStarRepository; private final ImageService imageService; private final MarkdownService markdownService; + private final EmbeddingModel embeddingModel; public RecipeServiceImpl( RecipeRepository recipeRepository, RecipeStarRepository recipeStarRepository, ImageService imageService, - MarkdownService markdownService + MarkdownService markdownService, + EmbeddingModel embeddingModel ) { this.recipeRepository = recipeRepository; this.recipeStarRepository = recipeStarRepository; this.imageService = imageService; this.markdownService = markdownService; + this.embeddingModel = embeddingModel; } @Override @@ -93,7 +99,9 @@ public class RecipeServiceImpl implements RecipeService { )); } - private String getRenderedMarkdown(RecipeEntity entity) { + @Override + @ApiStatus.Internal + public String getRenderedMarkdown(RecipeEntity entity) { if (entity.getCachedRenderedText() == null) { entity.setCachedRenderedText(this.markdownService.renderAndCleanMarkdown(entity.getRawText())); entity = this.recipeRepository.save(entity); @@ -191,6 +199,20 @@ public class RecipeServiceImpl implements RecipeService { return List.copyOf(this.recipeRepository.findAllByOwner((UserEntity) owner)); } + @Override + public List aiSearch(RecipeAiSearchSpec searchSpec, @Nullable User viewer) { + final float[] queryEmbedding = this.embeddingModel.embed(searchSpec.getPrompt()); + final List results; + if (viewer == null) { + results = this.recipeRepository.searchByEmbeddingAndIsPublic(queryEmbedding, 0.5f); + } else { + results = this.recipeRepository.searchByEmbeddingAndViewableBy(queryEmbedding, 0.5f, viewer.getId()); + } + return results.stream() + .map(recipeEntity -> this.getInfoView(recipeEntity, viewer)) + .toList(); + } + @Override @PreAuthorize("@recipeSecurity.isOwner(#username, #slug, #modifier)") public Recipe update(String username, String slug, RecipeUpdateSpec spec, User modifier) diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/body/RecipeSearchBody.java b/src/main/java/app/mealsmadeeasy/api/recipe/body/RecipeSearchBody.java new file mode 100644 index 0000000..5660ac9 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/recipe/body/RecipeSearchBody.java @@ -0,0 +1,30 @@ +package app.mealsmadeeasy.api.recipe.body; + +import java.util.Map; + +public class RecipeSearchBody { + + public enum Type { + AI_PROMPT + } + + private Type type; + private Map data; + + public Type getType() { + return this.type; + } + + public void setType(Type type) { + this.type = type; + } + + public Map getData() { + return this.data; + } + + public void setData(Map data) { + this.data = data; + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/spec/RecipeAiSearchSpec.java b/src/main/java/app/mealsmadeeasy/api/recipe/spec/RecipeAiSearchSpec.java new file mode 100644 index 0000000..37b0eb6 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/recipe/spec/RecipeAiSearchSpec.java @@ -0,0 +1,15 @@ +package app.mealsmadeeasy.api.recipe.spec; + +public class RecipeAiSearchSpec { + + private String prompt; + + public String getPrompt() { + return this.prompt; + } + + public void setPrompt(String prompt) { + this.prompt = prompt; + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index f933cff..1e6e764 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -16,3 +16,6 @@ app.mealsmadeeasy.api.minio.endpoint=http://${MINIO_HOST:localhost}:${MINIO_PORT app.mealsmadeeasy.api.minio.accessKey=${MINIO_ROOT_USER} app.mealsmadeeasy.api.minio.secretKey=${MINIO_ROOT_PASSWORD} app.mealsmadeeasy.api.images.bucketName=images + +# AI +spring.ai.vectorstore.pgvector.dimensions=1024 diff --git a/src/main/resources/db/migration/V3__create_vector_extension.sql b/src/main/resources/db/migration/V3__create_vector_extension.sql new file mode 100644 index 0000000..621c34a --- /dev/null +++ b/src/main/resources/db/migration/V3__create_vector_extension.sql @@ -0,0 +1 @@ +CREATE EXTENSION vector; diff --git a/src/main/resources/db/migration/V4__create_recipe_embedding.sql b/src/main/resources/db/migration/V4__create_recipe_embedding.sql new file mode 100644 index 0000000..2551446 --- /dev/null +++ b/src/main/resources/db/migration/V4__create_recipe_embedding.sql @@ -0,0 +1,7 @@ +CREATE TABLE recipe_embedding ( + recipe_id INT NOT NULL, + timestamp TIMESTAMPTZ(6) NOT NULL, + embedding VECTOR(1024), + PRIMARY KEY (recipe_id), + FOREIGN KEY (recipe_id) REFERENCES recipe ON DELETE CASCADE +);