From 3a5e8d711eaa0bd3f329248aeb25a711336aabce Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Sun, 15 Mar 2026 17:07:48 +0800 Subject: [PATCH] feat(search): add semantic reranking foundation --- docs/04-search-architecture.md | 7 ++ .../src/main/resources/application.yml | 5 + .../V11__skill_search_semantic_vector.sql | 2 + .../infra/jpa/SkillSearchDocumentEntity.java | 13 +++ .../jpa/SkillSearchDocumentJpaRepository.java | 3 + .../search/HashingSearchEmbeddingService.java | 83 ++++++++++++++ .../search/SearchEmbeddingService.java | 7 ++ .../skillhub/search/SkillSearchDocument.java | 1 + .../PostgresFullTextIndexService.java | 20 +++- .../PostgresFullTextQueryService.java | 101 +++++++++++++++++- .../PostgresSearchRebuildService.java | 1 + .../HashingSearchEmbeddingServiceTest.java | 29 +++++ .../PostgresFullTextQueryServiceTest.java | 50 +++++++++ 13 files changed, 319 insertions(+), 3 deletions(-) create mode 100644 server/skillhub-app/src/main/resources/db/migration/V11__skill_search_semantic_vector.sql create mode 100644 server/skillhub-search/src/main/java/com/iflytek/skillhub/search/HashingSearchEmbeddingService.java create mode 100644 server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchEmbeddingService.java create mode 100644 server/skillhub-search/src/test/java/com/iflytek/skillhub/search/HashingSearchEmbeddingServiceTest.java diff --git a/docs/04-search-architecture.md b/docs/04-search-architecture.md index 5dfe3b90..098f2e66 100644 --- a/docs/04-search-architecture.md +++ b/docs/04-search-architecture.md @@ -107,10 +107,17 @@ PostgreSQL 全文搜索索引:表增加 `search_vector tsvector` 生成列, | 阶段 | 实现 | 索引粒度 | 切换方式 | |------|------|---------|---------| | 一期 | PostgreSQL Full-Text (tsvector + GIN) | 每 skill 一条(latest_version_id) | 默认 | +| 一点五期 | PostgreSQL Full-Text + 语义向量重排 | 每 skill 一条(latest_version_id) | 配置 `skillhub.search.semantic.enabled=true` | | 二期 | ES / OpenSearch | 每 skill_version 一条 + skill 聚合文档 | 配置 `search.provider=elasticsearch` | | 三期 | 向量检索 | 每 skill_version 多条(chunk 级) | 配置 `search.provider=vector` | | 四期 | 混合排序 | 关键词 + 向量混合 | 配置 `search.provider=hybrid` | +当前代码实现已落在“一点五期”: +- 仍然使用 PostgreSQL 全文搜索作为主召回 +- 搜索文档表新增 `semantic_vector` 缓存字段 +- relevance 排序下,对全文候选集追加语义向量重排 +- 语义向量不可用时自动降级为现有全文相关度排序 + ### 5.3 SPI 演进策略 一期 SPI 接口(`SearchIndexService` / `SearchQueryService`)的入参是 `SkillSearchDocument`(skill 粒度)。二期切换到 ES 时: diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 9655837c..f999efe3 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -87,6 +87,11 @@ skillhub: search: engine: postgres rebuild-on-startup: false + semantic: + enabled: true + weight: 0.35 + candidate-multiplier: 8 + max-candidates: 120 publish: max-file-count: 100 max-single-file-size: 1048576 # 1MB diff --git a/server/skillhub-app/src/main/resources/db/migration/V11__skill_search_semantic_vector.sql b/server/skillhub-app/src/main/resources/db/migration/V11__skill_search_semantic_vector.sql new file mode 100644 index 00000000..ddce45d4 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V11__skill_search_semantic_vector.sql @@ -0,0 +1,2 @@ +ALTER TABLE skill_search_document +ADD COLUMN semantic_vector TEXT; diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentEntity.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentEntity.java index 4eeae924..85f58db8 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentEntity.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentEntity.java @@ -35,6 +35,9 @@ public class SkillSearchDocumentEntity { @Column(name = "search_text", columnDefinition = "TEXT") private String searchText; + @Column(name = "semantic_vector", columnDefinition = "TEXT") + private String semanticVector; + @Column(nullable = false, length = 20) private String visibility; @@ -56,6 +59,7 @@ public class SkillSearchDocumentEntity { String summary, String keywords, String searchText, + String semanticVector, String visibility, String status) { this.skillId = skillId; @@ -66,6 +70,7 @@ public class SkillSearchDocumentEntity { this.summary = summary; this.keywords = keywords; this.searchText = searchText; + this.semanticVector = semanticVector; this.visibility = visibility; this.status = status; } @@ -117,6 +122,10 @@ public class SkillSearchDocumentEntity { return visibility; } + public String getSemanticVector() { + return semanticVector; + } + public String getStatus() { return status; } @@ -154,6 +163,10 @@ public class SkillSearchDocumentEntity { this.searchText = searchText; } + public void setSemanticVector(String semanticVector) { + this.semanticVector = semanticVector; + } + public void setVisibility(String visibility) { this.visibility = visibility; } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentJpaRepository.java index 7dd6fd62..f9e65ec5 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentJpaRepository.java @@ -3,10 +3,13 @@ package com.iflytek.skillhub.infra.jpa; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Collection; +import java.util.List; import java.util.Optional; @Repository public interface SkillSearchDocumentJpaRepository extends JpaRepository { Optional findBySkillId(Long skillId); + List findBySkillIdIn(Collection skillIds); void deleteBySkillId(Long skillId); } diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/HashingSearchEmbeddingService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/HashingSearchEmbeddingService.java new file mode 100644 index 00000000..1ed4a72c --- /dev/null +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/HashingSearchEmbeddingService.java @@ -0,0 +1,83 @@ +package com.iflytek.skillhub.search; + +import java.util.Arrays; +import java.util.Locale; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; + +@Service +public class HashingSearchEmbeddingService implements SearchEmbeddingService { + private static final Pattern TOKEN_SPLITTER = Pattern.compile("[^\\p{L}\\p{N}_]+"); + private static final int DIMENSIONS = 64; + + @Override + public String embed(String text) { + double[] vector = buildVector(text); + return Arrays.stream(vector) + .mapToObj(value -> String.format(Locale.ROOT, "%.6f", value)) + .collect(Collectors.joining(",")); + } + + @Override + public double similarity(String text, String serializedVector) { + if (serializedVector == null || serializedVector.isBlank()) { + return 0D; + } + double[] left = buildVector(text); + double[] right = parseVector(serializedVector); + if (left.length != right.length || left.length == 0) { + return 0D; + } + + double dot = 0D; + for (int i = 0; i < left.length; i++) { + dot += left[i] * right[i]; + } + return dot; + } + + private double[] buildVector(String text) { + double[] vector = new double[DIMENSIONS]; + if (text == null || text.isBlank()) { + return vector; + } + + TOKEN_SPLITTER.splitAsStream(text.toLowerCase(Locale.ROOT)) + .map(String::trim) + .filter(token -> !token.isBlank()) + .forEach(token -> { + int hash = token.hashCode(); + int index = Math.floorMod(hash, DIMENSIONS); + double weight = 1D + Math.min(token.length(), 12) / 12D; + vector[index] += weight; + }); + + normalize(vector); + return vector; + } + + private double[] parseVector(String serializedVector) { + String[] parts = serializedVector.split(","); + double[] vector = new double[parts.length]; + for (int i = 0; i < parts.length; i++) { + vector[i] = Double.parseDouble(parts[i]); + } + normalize(vector); + return vector; + } + + private void normalize(double[] vector) { + double magnitude = 0D; + for (double value : vector) { + magnitude += value * value; + } + if (magnitude == 0D) { + return; + } + double norm = Math.sqrt(magnitude); + for (int i = 0; i < vector.length; i++) { + vector[i] = vector[i] / norm; + } + } +} diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchEmbeddingService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchEmbeddingService.java new file mode 100644 index 00000000..4eec4795 --- /dev/null +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchEmbeddingService.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.search; + +public interface SearchEmbeddingService { + String embed(String text); + + double similarity(String text, String serializedVector); +} diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SkillSearchDocument.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SkillSearchDocument.java index d2eaec3c..d40fb09f 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SkillSearchDocument.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SkillSearchDocument.java @@ -9,6 +9,7 @@ public record SkillSearchDocument( String summary, String keywords, String searchText, + String semanticVector, String visibility, String status ) {} diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java index 9bfe64dc..7db8bcca 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.search.postgres; import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity; import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository; +import com.iflytek.skillhub.search.SearchEmbeddingService; import com.iflytek.skillhub.search.SearchIndexService; import com.iflytek.skillhub.search.SkillSearchDocument; import org.springframework.stereotype.Service; @@ -14,9 +15,12 @@ import java.util.Optional; public class PostgresFullTextIndexService implements SearchIndexService { private final SkillSearchDocumentJpaRepository repository; + private final SearchEmbeddingService searchEmbeddingService; - public PostgresFullTextIndexService(SkillSearchDocumentJpaRepository repository) { + public PostgresFullTextIndexService(SkillSearchDocumentJpaRepository repository, + SearchEmbeddingService searchEmbeddingService) { this.repository = repository; + this.searchEmbeddingService = searchEmbeddingService; } @Override @@ -33,6 +37,7 @@ public class PostgresFullTextIndexService implements SearchIndexService { entity.setSummary(document.summary()); entity.setKeywords(document.keywords()); entity.setSearchText(document.searchText()); + entity.setSemanticVector(buildSemanticVector(document)); entity.setVisibility(document.visibility()); entity.setStatus(document.status()); repository.save(entity); @@ -46,6 +51,7 @@ public class PostgresFullTextIndexService implements SearchIndexService { document.summary(), document.keywords(), document.searchText(), + buildSemanticVector(document), document.visibility(), document.status() ); @@ -66,4 +72,16 @@ public class PostgresFullTextIndexService implements SearchIndexService { public void remove(Long skillId) { repository.deleteBySkillId(skillId); } + + private String buildSemanticVector(SkillSearchDocument document) { + return searchEmbeddingService.embed(String.join("\n", + safe(document.title()), + safe(document.summary()), + safe(document.keywords()), + safe(document.searchText()))); + } + + private String safe(String value) { + return value == null ? "" : value; + } } diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java index 6114e35c..be7dae30 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java @@ -1,13 +1,21 @@ package com.iflytek.skillhub.search.postgres; +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity; +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository; +import com.iflytek.skillhub.search.SearchEmbeddingService; import com.iflytek.skillhub.search.SearchQuery; import com.iflytek.skillhub.search.SearchQueryService; import com.iflytek.skillhub.search.SearchResult; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; +import java.util.Comparator; +import java.util.HashMap; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.regex.Pattern; @@ -20,9 +28,32 @@ public class PostgresFullTextQueryService implements SearchQueryService { private static final String TITLE_SQL = "LOWER(title)"; private final EntityManager entityManager; + private final SkillSearchDocumentJpaRepository searchDocumentRepository; + private final SearchEmbeddingService searchEmbeddingService; + private final boolean semanticEnabled; + private final double semanticWeight; + private final int candidateMultiplier; + private final int maxCandidates; public PostgresFullTextQueryService(EntityManager entityManager) { + this(entityManager, null, null, false, 0.35D, 8, 120); + } + + @Autowired + public PostgresFullTextQueryService(EntityManager entityManager, + SkillSearchDocumentJpaRepository searchDocumentRepository, + SearchEmbeddingService searchEmbeddingService, + @Value("${skillhub.search.semantic.enabled:true}") boolean semanticEnabled, + @Value("${skillhub.search.semantic.weight:0.35}") double semanticWeight, + @Value("${skillhub.search.semantic.candidate-multiplier:8}") int candidateMultiplier, + @Value("${skillhub.search.semantic.max-candidates:120}") int maxCandidates) { this.entityManager = entityManager; + this.searchDocumentRepository = searchDocumentRepository; + this.searchEmbeddingService = searchEmbeddingService; + this.semanticEnabled = semanticEnabled; + this.semanticWeight = semanticWeight; + this.candidateMultiplier = candidateMultiplier; + this.maxCandidates = maxCandidates; } @Override @@ -31,6 +62,18 @@ public class PostgresFullTextQueryService implements SearchQueryService { String tsQuery = buildPrefixTsQuery(normalizedKeyword); boolean hasKeyword = tsQuery != null; boolean useShortPrefixTitleSearch = hasKeyword && normalizedKeyword.length() <= SHORT_PREFIX_LENGTH; + boolean useSemanticRerank = semanticEnabled + && hasKeyword + && "relevance".equals(query.sortBy()) + && searchDocumentRepository != null + && searchEmbeddingService != null; + int requestedOffset = query.page() * query.size(); + int sqlLimit = query.size(); + int sqlOffset = requestedOffset; + if (useSemanticRerank) { + sqlLimit = Math.min(Math.max((query.page() + 1) * query.size() * candidateMultiplier, query.size() * candidateMultiplier), maxCandidates); + sqlOffset = 0; + } Set memberNamespaceIds = query.visibilityScope().memberNamespaceIds().isEmpty() ? Set.of(-1L) : query.visibilityScope().memberNamespaceIds(); @@ -114,8 +157,8 @@ public class PostgresFullTextQueryService implements SearchQueryService { nativeQuery.setParameter("titleLike", "%" + normalizedKeyword.toLowerCase() + "%"); } - nativeQuery.setParameter("limit", query.size()); - nativeQuery.setParameter("offset", query.page() * query.size()); + nativeQuery.setParameter("limit", sqlLimit); + nativeQuery.setParameter("offset", sqlOffset); @SuppressWarnings("unchecked") List skillIds = (List) nativeQuery.getResultList().stream() @@ -152,9 +195,60 @@ public class PostgresFullTextQueryService implements SearchQueryService { long total = ((Number) countQuery.getSingleResult()).longValue(); + if (useSemanticRerank && !skillIds.isEmpty()) { + skillIds = rerankBySemanticSimilarity(skillIds, normalizedKeyword, requestedOffset, query.size()); + } + return new SearchResult(skillIds, total, query.page(), query.size()); } + private List rerankBySemanticSimilarity(List candidateSkillIds, + String normalizedKeyword, + int requestedOffset, + int pageSize) { + Map documentsBySkillId = new HashMap<>(); + for (SkillSearchDocumentEntity entity : searchDocumentRepository.findBySkillIdIn(candidateSkillIds)) { + documentsBySkillId.put(entity.getSkillId(), entity); + } + + int totalCandidates = Math.max(candidateSkillIds.size(), 1); + List rankedSkills = new java.util.ArrayList<>(candidateSkillIds.size()); + for (int index = 0; index < candidateSkillIds.size(); index++) { + Long skillId = candidateSkillIds.get(index); + SkillSearchDocumentEntity entity = documentsBySkillId.get(skillId); + double baseScore = 1D - (index / (double) totalCandidates); + double semanticScore = computeSemanticScore(normalizedKeyword, entity); + double combinedScore = (baseScore * (1D - semanticWeight)) + (semanticScore * semanticWeight); + rankedSkills.add(new RankedSkill(skillId, combinedScore)); + } + + return rankedSkills.stream() + .sorted(Comparator.comparingDouble(RankedSkill::score).reversed()) + .skip(requestedOffset) + .limit(pageSize) + .map(RankedSkill::skillId) + .toList(); + } + + private double computeSemanticScore(String normalizedKeyword, SkillSearchDocumentEntity entity) { + if (entity == null) { + return 0D; + } + String serializedVector = entity.getSemanticVector(); + if (serializedVector == null || serializedVector.isBlank()) { + serializedVector = searchEmbeddingService.embed(String.join("\n", + safe(entity.getTitle()), + safe(entity.getSummary()), + safe(entity.getKeywords()), + safe(entity.getSearchText()))); + } + return searchEmbeddingService.similarity(normalizedKeyword, serializedVector); + } + + private String safe(String value) { + return value == null ? "" : value; + } + private String normalizeKeyword(String keyword) { if (keyword == null || keyword.isBlank()) { return null; @@ -183,4 +277,7 @@ public class PostgresFullTextQueryService implements SearchQueryService { .reduce((left, right) -> left + " & " + right) .orElse(null); } + + private record RankedSkill(Long skillId, double score) { + } } diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java index 7d1a0b2e..59bd51ef 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java @@ -88,6 +88,7 @@ public class PostgresSearchRebuildService implements SearchRebuildService { skill.getSummary(), "", searchText, + null, skill.getVisibility().name(), skill.getStatus().name() )); diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/HashingSearchEmbeddingServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/HashingSearchEmbeddingServiceTest.java new file mode 100644 index 00000000..d916cc8d --- /dev/null +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/HashingSearchEmbeddingServiceTest.java @@ -0,0 +1,29 @@ +package com.iflytek.skillhub.search; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class HashingSearchEmbeddingServiceTest { + + private final HashingSearchEmbeddingService service = new HashingSearchEmbeddingService(); + + @Test + void embedShouldBeDeterministic() { + String first = service.embed("self improving skill"); + String second = service.embed("self improving skill"); + + assertThat(first).isEqualTo(second); + } + + @Test + void similarityShouldFavorCloserText() { + String relevantVector = service.embed("self improvement productivity habit tracker"); + String noisyVector = service.embed("web search keywords company research"); + + double relevant = service.similarity("self improvement", relevantVector); + double noisy = service.similarity("self improvement", noisyVector); + + assertThat(relevant).isGreaterThan(noisy); + } +} diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java index b3c909a6..bc7f0e21 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -1,5 +1,8 @@ package com.iflytek.skillhub.search.postgres; +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity; +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository; +import com.iflytek.skillhub.search.HashingSearchEmbeddingService; import com.iflytek.skillhub.search.SearchQuery; import com.iflytek.skillhub.search.SearchVisibilityScope; import jakarta.persistence.EntityManager; @@ -165,4 +168,51 @@ class PostgresFullTextQueryServiceTest { verify(nativeQuery).setParameter("tsQuery", "self:* & improving:*"); verify(countQuery).setParameter("tsQuery", "self:* & improving:*"); } + + @Test + void semanticRerankShouldPromoteSemanticallyRelevantCandidate() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + SkillSearchDocumentJpaRepository repository = mock(SkillSearchDocumentJpaRepository.class); + HashingSearchEmbeddingService embeddingService = new HashingSearchEmbeddingService(); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of(2L, 1L)); + when(countQuery.getSingleResult()).thenReturn(2L); + when(repository.findBySkillIdIn(List.of(2L, 1L))).thenReturn(List.of( + new SkillSearchDocumentEntity(1L, 1L, "global", "user-1", "Self Improvement Coach", + "Build better habits", "habits,self improvement", "habit tracker and self improvement guide", + embeddingService.embed("habit tracker and self improvement guide"), "PUBLIC", "ACTIVE"), + new SkillSearchDocumentEntity(2L, 1L, "global", "user-2", "Web Search Exa", + "Research assistant", "keywords,search", "web search keywords company research", + embeddingService.embed("web search keywords company research"), "PUBLIC", "ACTIVE") + )); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService( + entityManager, + repository, + embeddingService, + true, + 0.6D, + 8, + 120 + ); + + var result = service.search(new SearchQuery( + "self improvement", + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "relevance", + 0, + 2 + )); + + verify(nativeQuery).setParameter("limit", 16); + verify(nativeQuery).setParameter("offset", 0); + assertThat(result.skillIds()).containsExactly(1L, 2L); + } }