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 dc740b44..6114e35c 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 @@ -17,6 +17,7 @@ public class PostgresFullTextQueryService implements SearchQueryService { private static final int MAX_QUERY_TERMS = 8; private static final int SHORT_PREFIX_LENGTH = 2; private static final String TITLE_VECTOR_SQL = "to_tsvector('simple', coalesce(title, ''))"; + private static final String TITLE_SQL = "LOWER(title)"; private final EntityManager entityManager; @@ -58,11 +59,14 @@ public class PostgresFullTextQueryService implements SearchQueryService { // Full-text search if (hasKeyword) { + sql.append("AND ("); if (useShortPrefixTitleSearch) { - sql.append("AND ").append(TITLE_VECTOR_SQL).append(" @@ to_tsquery('simple', :tsQuery) "); + sql.append(TITLE_VECTOR_SQL).append(" @@ to_tsquery('simple', :tsQuery) "); } else { - sql.append("AND search_vector @@ to_tsquery('simple', :tsQuery) "); + sql.append("search_vector @@ to_tsquery('simple', :tsQuery) "); } + sql.append(" OR ").append(TITLE_SQL).append(" LIKE :titleLike"); + sql.append(") "); } // Sorting @@ -73,11 +77,16 @@ public class PostgresFullTextQueryService implements SearchQueryService { } 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()) && hasKeyword) { + sql.append("ORDER BY CASE "); + sql.append("WHEN ").append(TITLE_SQL).append(" = :titleExact THEN 4 "); + sql.append("WHEN ").append(TITLE_SQL).append(" LIKE :titlePrefix THEN 3 "); + sql.append("WHEN ").append(TITLE_SQL).append(" LIKE :titleLike THEN 2 "); + sql.append("ELSE 1 END DESC, "); if (useShortPrefixTitleSearch) { - sql.append("ORDER BY ts_rank_cd(").append(TITLE_VECTOR_SQL) + sql.append("ts_rank_cd(").append(TITLE_VECTOR_SQL) .append(", to_tsquery('simple', :tsQuery)) DESC, updated_at DESC "); } else { - sql.append("ORDER BY ts_rank_cd(search_vector, to_tsquery('simple', :tsQuery)) DESC, updated_at DESC "); + sql.append("ts_rank_cd(search_vector, to_tsquery('simple', :tsQuery)) DESC, updated_at DESC "); } } else { sql.append("ORDER BY updated_at DESC "); @@ -100,6 +109,9 @@ public class PostgresFullTextQueryService implements SearchQueryService { if (hasKeyword) { nativeQuery.setParameter("tsQuery", tsQuery); + nativeQuery.setParameter("titleExact", normalizedKeyword.toLowerCase()); + nativeQuery.setParameter("titlePrefix", normalizedKeyword.toLowerCase() + "%"); + nativeQuery.setParameter("titleLike", "%" + normalizedKeyword.toLowerCase() + "%"); } nativeQuery.setParameter("limit", query.size()); @@ -135,6 +147,7 @@ public class PostgresFullTextQueryService implements SearchQueryService { if (hasKeyword) { countQuery.setParameter("tsQuery", tsQuery); + countQuery.setParameter("titleLike", "%" + normalizedKeyword.toLowerCase() + "%"); } long total = ((Number) countQuery.getSingleResult()).longValue(); @@ -146,7 +159,7 @@ public class PostgresFullTextQueryService implements SearchQueryService { if (keyword == null || keyword.isBlank()) { return null; } - return keyword.trim(); + return keyword.trim().toLowerCase(); } private String buildPrefixTsQuery(String keyword) { 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 9298b3db..b3c909a6 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 @@ -47,6 +47,7 @@ class PostgresFullTextQueryServiceTest { var sqlCaptor = org.mockito.ArgumentCaptor.forClass(String.class); verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); assertThat(sqlCaptor.getAllValues().getFirst()).contains("to_tsvector('simple', coalesce(title, '')) @@ to_tsquery('simple', :tsQuery)"); + assertThat(sqlCaptor.getAllValues().getFirst()).contains("LOWER(title) LIKE :titleLike"); } @Test @@ -107,6 +108,36 @@ class PostgresFullTextQueryServiceTest { assertThat(sqlCaptor.getAllValues().getFirst()).contains("ts_rank_cd(search_vector, to_tsquery('simple', :tsQuery))"); } + @Test + void shortPrefixRelevanceShouldRankUsingTitleVectorWithoutDuplicateOrderBy() { + 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( + "x", + 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("ts_rank_cd(to_tsvector('simple', coalesce(title, '')), to_tsquery('simple', :tsQuery))"); + assertThat(sqlCaptor.getAllValues().getFirst()).doesNotContain("ORDER BY ORDER BY"); + } + @Test void multipleTermsShouldBuildPrefixQueryForEachLexeme() { EntityManager entityManager = mock(EntityManager.class);