From df850d1f63523900e6918e1ac4e8dcfcbcf55c84 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 19:30:15 +0800 Subject: [PATCH] fix(search): support short skill queries --- server/skillhub-search/pom.xml | 5 + .../PostgresFullTextQueryService.java | 42 +++++-- .../PostgresFullTextQueryServiceTest.java | 110 ++++++++++++++++++ 3 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java diff --git a/server/skillhub-search/pom.xml b/server/skillhub-search/pom.xml index 573a4869..222d2980 100644 --- a/server/skillhub-search/pom.xml +++ b/server/skillhub-search/pom.xml @@ -27,5 +27,10 @@ org.springframework spring-context + + org.springframework.boot + spring-boot-starter-test + test + 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 12727810..b23d5f14 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 @@ -12,6 +12,7 @@ import java.util.Set; @Service public class PostgresFullTextQueryService implements SearchQueryService { + private static final int SHORT_KEYWORD_LENGTH = 2; private final EntityManager entityManager; @@ -21,6 +22,9 @@ public class PostgresFullTextQueryService implements SearchQueryService { @Override public SearchResult search(SearchQuery query) { + String normalizedKeyword = normalizeKeyword(query.keyword()); + boolean hasKeyword = normalizedKeyword != null; + boolean useShortKeywordFallback = hasKeyword && normalizedKeyword.length() <= SHORT_KEYWORD_LENGTH; Set memberNamespaceIds = query.visibilityScope().memberNamespaceIds().isEmpty() ? Set.of(-1L) : query.visibilityScope().memberNamespaceIds(); @@ -48,8 +52,17 @@ public class PostgresFullTextQueryService implements SearchQueryService { } // Full-text search - if (query.keyword() != null && !query.keyword().isBlank()) { - sql.append("AND search_vector @@ plainto_tsquery('simple', :keyword) "); + if (hasKeyword) { + if (useShortKeywordFallback) { + sql.append("AND ("); + sql.append("LOWER(title) LIKE LOWER(:keywordLike) "); + sql.append("OR LOWER(summary) LIKE LOWER(:keywordLike) "); + sql.append("OR LOWER(keywords) LIKE LOWER(:keywordLike) "); + sql.append("OR LOWER(search_text) LIKE LOWER(:keywordLike)"); + sql.append(") "); + } else { + sql.append("AND search_vector @@ plainto_tsquery('simple', :keyword) "); + } } // Sorting @@ -59,7 +72,7 @@ public class PostgresFullTextQueryService implements SearchQueryService { sql.append("ORDER BY (SELECT rating_avg FROM skill WHERE id = skill_id) DESC "); } else if ("newest".equals(query.sortBy())) { sql.append("ORDER BY (SELECT updated_at FROM skill WHERE id = skill_id) DESC "); - } else if ("relevance".equals(query.sortBy()) && query.keyword() != null && !query.keyword().isBlank()) { + } else if ("relevance".equals(query.sortBy()) && hasKeyword && !useShortKeywordFallback) { sql.append("ORDER BY ts_rank(search_vector, plainto_tsquery('simple', :keyword)) DESC "); } else { sql.append("ORDER BY updated_at DESC "); @@ -80,8 +93,12 @@ public class PostgresFullTextQueryService implements SearchQueryService { nativeQuery.setParameter("namespaceId", query.namespaceId()); } - if (query.keyword() != null && !query.keyword().isBlank()) { - nativeQuery.setParameter("keyword", query.keyword()); + if (hasKeyword) { + if (useShortKeywordFallback) { + nativeQuery.setParameter("keywordLike", "%" + normalizedKeyword + "%"); + } else { + nativeQuery.setParameter("keyword", normalizedKeyword); + } } nativeQuery.setParameter("limit", query.size()); @@ -115,12 +132,23 @@ public class PostgresFullTextQueryService implements SearchQueryService { countQuery.setParameter("namespaceId", query.namespaceId()); } - if (query.keyword() != null && !query.keyword().isBlank()) { - countQuery.setParameter("keyword", query.keyword()); + if (hasKeyword) { + if (useShortKeywordFallback) { + countQuery.setParameter("keywordLike", "%" + normalizedKeyword + "%"); + } else { + countQuery.setParameter("keyword", normalizedKeyword); + } } long total = ((Number) countQuery.getSingleResult()).longValue(); return new SearchResult(skillIds, total, query.page(), query.size()); } + + private String normalizeKeyword(String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + return keyword.trim(); + } } 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 new file mode 100644 index 00000000..e91954d9 --- /dev/null +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -0,0 +1,110 @@ +package com.iflytek.skillhub.search.postgres; + +import com.iflytek.skillhub.search.SearchQuery; +import com.iflytek.skillhub.search.SearchVisibilityScope; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PostgresFullTextQueryServiceTest { + + @Test + void shortKeywordsShouldUseLikeFallback() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + 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(1L)); + when(countQuery.getSingleResult()).thenReturn(1L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + "ai", + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "relevance", + 0, + 20 + )); + + verify(nativeQuery).setParameter("keywordLike", "%ai%"); + verify(countQuery).setParameter("keywordLike", "%ai%"); + verify(nativeQuery, never()).setParameter("keyword", "ai"); + verify(countQuery, never()).setParameter("keyword", "ai"); + } + + @Test + void longerKeywordsShouldKeepFullTextSearch() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + 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(1L)); + when(countQuery.getSingleResult()).thenReturn(1L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + "agent", + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "relevance", + 0, + 20 + )); + + verify(nativeQuery).setParameter("keyword", "agent"); + verify(countQuery).setParameter("keyword", "agent"); + verify(nativeQuery, never()).setParameter("keywordLike", "%agent%"); + verify(countQuery, never()).setParameter("keywordLike", "%agent%"); + } + + @Test + void shortKeywordSqlShouldAvoidTsRankOrdering() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + 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()); + when(countQuery.getSingleResult()).thenReturn(0L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + "go", + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "relevance", + 0, + 20 + )); + + var sqlCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getAllValues().getFirst()).contains("LOWER(title) LIKE LOWER(:keywordLike)"); + assertThat(sqlCaptor.getAllValues().getFirst()).doesNotContain("ts_rank"); + } +}