fix(search): improve short skill query matching

This commit is contained in:
yun-zhi-ztl 2026-03-15 13:44:07 +08:00
parent 835b0da397
commit 091a5fe190
2 changed files with 49 additions and 5 deletions

View file

@ -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) {

View file

@ -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);