fix(search): support short skill queries

This commit is contained in:
yun-zhi-ztl 2026-03-14 19:30:15 +08:00
parent 725b71d323
commit df850d1f63
3 changed files with 150 additions and 7 deletions

View file

@ -27,5 +27,10 @@
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View file

@ -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<Long> 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();
}
}

View file

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