feat: opensearch init

This commit is contained in:
tim
2025-09-26 16:37:13 +08:00
parent 69869348f6
commit 0bc65077df
23 changed files with 874 additions and 18 deletions

View File

@@ -11,14 +11,26 @@ import com.openisle.repository.CommentRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.TagRepository;
import com.openisle.repository.UserRepository;
import com.openisle.search.OpenSearchProperties;
import com.openisle.search.SearchDocument;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.opensearch.client.opensearch.OpenSearchClient;
import org.opensearch.client.opensearch._types.query_dsl.TextQueryType;
import org.opensearch.client.opensearch.core.SearchResponse;
import org.opensearch.client.opensearch.core.search.Hit;
import org.springframework.stereotype.Service;
@Service
@Slf4j
@RequiredArgsConstructor
public class SearchService {
@@ -27,10 +39,14 @@ public class SearchService {
private final CommentRepository commentRepository;
private final CategoryRepository categoryRepository;
private final TagRepository tagRepository;
private final Optional<OpenSearchClient> openSearchClient;
private final OpenSearchProperties openSearchProperties;
@org.springframework.beans.factory.annotation.Value("${app.snippet-length}")
private int snippetLength;
private static final int DEFAULT_OPEN_SEARCH_LIMIT = 50;
public List<User> searchUsers(String keyword) {
return userRepository.findByUsernameContainingIgnoreCase(keyword);
}
@@ -64,6 +80,23 @@ public class SearchService {
}
public List<SearchResult> globalSearch(String keyword) {
if (keyword == null || keyword.isBlank()) {
return List.of();
}
if (isOpenSearchEnabled()) {
try {
List<SearchResult> results = searchWithOpenSearch(keyword);
if (!results.isEmpty()) {
return results;
}
} catch (IOException e) {
log.warn("OpenSearch global search failed, falling back to database query", e);
}
}
return fallbackGlobalSearch(keyword);
}
private List<SearchResult> fallbackGlobalSearch(String keyword) {
Stream<SearchResult> users = searchUsers(keyword)
.stream()
.map(u ->
@@ -138,6 +171,143 @@ public class SearchService {
.toList();
}
private boolean isOpenSearchEnabled() {
return openSearchProperties.isEnabled() && openSearchClient.isPresent();
}
private List<SearchResult> searchWithOpenSearch(String keyword) throws IOException {
OpenSearchClient client = openSearchClient.orElse(null);
if (client == null) {
return List.of();
}
String trimmed = keyword.trim();
SearchResponse<SearchDocument> response = client.search(
builder ->
builder
.index(searchIndices())
.query(q ->
q.multiMatch(mm ->
mm
.query(trimmed)
.fields(List.of("title^3", "content^2", "author^2", "category", "tags"))
.type(TextQueryType.BestFields)
)
)
.highlight(h ->
h
.preTags("<mark>")
.postTags("</mark>")
.fields("content", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1))
.fields("title", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1))
)
.size(DEFAULT_OPEN_SEARCH_LIMIT),
SearchDocument.class
);
return mapHits(response.hits().hits(), trimmed);
}
private int highlightFragmentSize() {
int configured = openSearchProperties.getHighlightFragmentSize();
if (configured > 0) {
return configured;
}
if (snippetLength > 0) {
return snippetLength;
}
return 200;
}
private List<String> searchIndices() {
return List.of(
openSearchProperties.postsIndex(),
openSearchProperties.commentsIndex(),
openSearchProperties.usersIndex(),
openSearchProperties.categoriesIndex(),
openSearchProperties.tagsIndex()
);
}
private List<SearchResult> mapHits(List<Hit<SearchDocument>> hits, String keyword) {
List<SearchResult> results = new ArrayList<>();
for (Hit<SearchDocument> hit : hits) {
SearchResult result = mapHit(hit, keyword);
if (result != null) {
results.add(result);
}
}
return results;
}
private SearchResult mapHit(Hit<SearchDocument> hit, String keyword) {
SearchDocument document = hit.source();
if (document == null || document.entityId() == null) {
return null;
}
Map<String, List<String>> highlight = hit.highlight();
String highlightedContent = firstHighlight(highlight, "content");
String highlightedTitle = firstHighlight(highlight, "title");
boolean highlightTitle = highlightedTitle != null && !highlightedTitle.isBlank();
String documentType = document.type() != null ? document.type() : "";
String effectiveType = documentType;
if ("post".equals(documentType) && highlightTitle) {
effectiveType = "post_title";
}
String snippet = highlightedContent != null && !highlightedContent.isBlank()
? cleanHighlight(highlightedContent)
: null;
if (snippet == null && highlightTitle) {
snippet = cleanHighlight(highlightedTitle);
}
boolean fromStart = "post_title".equals(effectiveType);
if (snippet == null || snippet.isBlank()) {
snippet = fallbackSnippet(document.content(), keyword, fromStart);
}
if (snippet == null) {
snippet = "";
}
String subText = null;
Long postId = null;
if ("post".equals(documentType) || "post_title".equals(effectiveType)) {
subText = document.category();
} else if ("comment".equals(documentType)) {
subText = document.author();
postId = document.postId();
}
return new SearchResult(
effectiveType,
document.entityId(),
document.title(),
subText,
snippet,
postId
);
}
private String firstHighlight(Map<String, List<String>> highlight, String field) {
if (highlight == null || field == null) {
return null;
}
List<String> values = highlight.get(field);
if (values == null || values.isEmpty()) {
return null;
}
return values.get(0);
}
private String cleanHighlight(String value) {
if (value == null) {
return null;
}
return value.replaceAll("<[^>]+>", "");
}
private String fallbackSnippet(String content, String keyword, boolean fromStart) {
if (content == null) {
return "";
}
return extractSnippet(content, keyword, fromStart);
}
private String extractSnippet(String content, String keyword, boolean fromStart) {
if (content == null) return "";
int limit = snippetLength;