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");
+ }
+}