fix: 索引/查询规则微调

This commit is contained in:
Tim
2025-09-28 17:58:10 +08:00
parent c8a1e6d8c8
commit e3f680ad0f
2 changed files with 184 additions and 99 deletions

View File

@@ -52,15 +52,17 @@ public class SearchIndexInitializer {
} }
} }
// SearchIndexInitializer.java —— 只贴需要替换/新增的方法
private TypeMapping postMapping() { private TypeMapping postMapping() {
return TypeMapping.of(builder -> return TypeMapping.of(builder ->
builder builder
.properties("type", Property.of(p -> p.keyword(k -> k))) .properties("type", Property.of(p -> p.keyword(k -> k)))
.properties("title", textWithPinyin()) .properties("title", textWithRawAndPinyin())
.properties("content", textWithPinyin()) .properties("content", textWithPinyinOnly()) // content 不做 .raw避免超长 keyword
.properties("author", keywordWithPinyin()) .properties("author", keywordWithRawAndPinyin())
.properties("category", keywordWithPinyin()) .properties("category", keywordWithRawAndPinyin())
.properties("tags", keywordWithPinyin()) .properties("tags", keywordWithRawAndPinyin())
.properties("postId", Property.of(p -> p.long_(l -> l))) .properties("postId", Property.of(p -> p.long_(l -> l)))
.properties( .properties(
"createdAt", "createdAt",
@@ -73,11 +75,11 @@ public class SearchIndexInitializer {
return TypeMapping.of(builder -> return TypeMapping.of(builder ->
builder builder
.properties("type", Property.of(p -> p.keyword(k -> k))) .properties("type", Property.of(p -> p.keyword(k -> k)))
.properties("title", textWithPinyin()) .properties("title", textWithRawAndPinyin())
.properties("content", textWithPinyin()) .properties("content", textWithPinyinOnly())
.properties("author", keywordWithPinyin()) .properties("author", keywordWithRawAndPinyin())
.properties("category", keywordWithPinyin()) .properties("category", keywordWithRawAndPinyin())
.properties("tags", keywordWithPinyin()) .properties("tags", keywordWithRawAndPinyin())
.properties("postId", Property.of(p -> p.long_(l -> l))) .properties("postId", Property.of(p -> p.long_(l -> l)))
.properties( .properties(
"createdAt", "createdAt",
@@ -90,8 +92,8 @@ public class SearchIndexInitializer {
return TypeMapping.of(builder -> return TypeMapping.of(builder ->
builder builder
.properties("type", Property.of(p -> p.keyword(k -> k))) .properties("type", Property.of(p -> p.keyword(k -> k)))
.properties("title", textWithPinyin()) .properties("title", textWithRawAndPinyin())
.properties("content", textWithPinyin()) .properties("content", textWithPinyinOnly())
.properties( .properties(
"createdAt", "createdAt",
Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis"))) Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis")))
@@ -103,8 +105,8 @@ public class SearchIndexInitializer {
return TypeMapping.of(builder -> return TypeMapping.of(builder ->
builder builder
.properties("type", Property.of(p -> p.keyword(k -> k))) .properties("type", Property.of(p -> p.keyword(k -> k)))
.properties("title", textWithPinyin()) .properties("title", textWithRawAndPinyin())
.properties("content", textWithPinyin()) .properties("content", textWithPinyinOnly())
); );
} }
@@ -112,8 +114,8 @@ public class SearchIndexInitializer {
return TypeMapping.of(builder -> return TypeMapping.of(builder ->
builder builder
.properties("type", Property.of(p -> p.keyword(k -> k))) .properties("type", Property.of(p -> p.keyword(k -> k)))
.properties("title", textWithPinyin()) .properties("title", textWithRawAndPinyin())
.properties("content", textWithPinyin()) .properties("content", textWithPinyinOnly())
.properties( .properties(
"createdAt", "createdAt",
Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis"))) Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis")))
@@ -121,45 +123,100 @@ public class SearchIndexInitializer {
); );
} }
private Property textWithPinyin() { // SearchIndexInitializer.java —— 只贴需要替换/新增的方法
/** 文本字段:.rawkeyword 精确) + .py拼音短语精确 + .zhICU+2~3gram 召回) */
private Property textWithRawAndPinyin() {
return Property.of(p -> return Property.of(p ->
p.text(t -> p.text(t ->
t.fields("py", field -> t
field.text(sub -> sub.analyzer("py_index").searchAnalyzer("py_search")) .fields("raw", f -> f.keyword(k -> k.normalizer("lowercase_normalizer")))
) .fields("py", f -> f.text(sub -> sub.analyzer("py_index").searchAnalyzer("py_search")))
.fields("zh", f ->
f.text(sub -> sub.analyzer("zh_ngram_index").searchAnalyzer("zh_search"))
)
) )
); );
} }
private Property keywordWithPinyin() { /** 长文本 content保留拼音 + 新增 zh 子字段(不加 .raw避免过长 keyword */
private Property textWithPinyinOnly() {
return Property.of(p ->
p.text(t ->
t
.fields("py", f -> f.text(sub -> sub.analyzer("py_index").searchAnalyzer("py_search")))
.fields("zh", f ->
f.text(sub -> sub.analyzer("zh_ngram_index").searchAnalyzer("zh_search"))
)
)
);
}
/** 关键词字段author/category/tagskeyword 等值 + .py + .zh尽量对齐标题策略 */
private Property keywordWithRawAndPinyin() {
return Property.of(p -> return Property.of(p ->
p.keyword(k -> p.keyword(k ->
k.fields("py", field -> k
field.text(sub -> sub.analyzer("py_index").searchAnalyzer("py_search")) .normalizer("lowercase_normalizer")
) .fields("raw", f -> f.keyword(kk -> kk.normalizer("lowercase_normalizer")))
.fields("py", f -> f.text(sub -> sub.analyzer("py_index").searchAnalyzer("py_search")))
.fields("zh", f ->
f.text(sub -> sub.analyzer("zh_ngram_index").searchAnalyzer("zh_search"))
)
) )
); );
} }
/** 新增 zh 分析器ICU + 2~3gram并保留你已有的 pinyin/normalizer 设置 */
private IndexSettings.Builder applyPinyinAnalysis(IndexSettings.Builder builder) { private IndexSettings.Builder applyPinyinAnalysis(IndexSettings.Builder builder) {
Map<String, JsonData> settings = new LinkedHashMap<>(); Map<String, JsonData> settings = new LinkedHashMap<>();
// --- 已有keyword normalizer用于 .raw
settings.put("analysis.normalizer.lowercase_normalizer.type", JsonData.of("custom"));
settings.put(
"analysis.normalizer.lowercase_normalizer.filter",
JsonData.of(List.of("lowercase"))
);
// --- 已有pinyin filter + analyzers
settings.put("analysis.filter.py_filter.type", JsonData.of("pinyin")); settings.put("analysis.filter.py_filter.type", JsonData.of("pinyin"));
settings.put("analysis.filter.py_filter.keep_full_pinyin", JsonData.of(true)); settings.put("analysis.filter.py_filter.keep_full_pinyin", JsonData.of(true));
settings.put("analysis.filter.py_filter.keep_joined_full_pinyin", JsonData.of(true)); settings.put("analysis.filter.py_filter.keep_joined_full_pinyin", JsonData.of(true));
settings.put("analysis.filter.py_filter.keep_first_letter", JsonData.of(false)); settings.put("analysis.filter.py_filter.keep_first_letter", JsonData.of(false));
settings.put("analysis.filter.py_filter.remove_duplicated_term", JsonData.of(true)); settings.put("analysis.filter.py_filter.remove_duplicated_term", JsonData.of(true));
settings.put("analysis.analyzer.py_index.type", JsonData.of("custom")); settings.put("analysis.analyzer.py_index.type", JsonData.of("custom"));
settings.put("analysis.analyzer.py_index.tokenizer", JsonData.of("standard")); settings.put("analysis.analyzer.py_index.tokenizer", JsonData.of("standard"));
settings.put( settings.put(
"analysis.analyzer.py_index.filter", "analysis.analyzer.py_index.filter",
JsonData.of(List.of("lowercase", "py_filter")) JsonData.of(List.of("lowercase", "py_filter"))
); );
settings.put("analysis.analyzer.py_search.type", JsonData.of("custom")); settings.put("analysis.analyzer.py_search.type", JsonData.of("custom"));
settings.put("analysis.analyzer.py_search.tokenizer", JsonData.of("standard")); settings.put("analysis.analyzer.py_search.tokenizer", JsonData.of("standard"));
settings.put( settings.put(
"analysis.analyzer.py_search.filter", "analysis.analyzer.py_search.filter",
JsonData.of(List.of("lowercase", "py_filter")) JsonData.of(List.of("lowercase", "py_filter"))
); );
settings.put("analysis.filter.zh_ngram_2_3.type", JsonData.of("ngram"));
settings.put("analysis.filter.zh_ngram_2_3.min_gram", JsonData.of(2));
settings.put("analysis.filter.zh_ngram_2_3.max_gram", JsonData.of(3));
settings.put("analysis.analyzer.zh_ngram_index.type", JsonData.of("custom"));
settings.put("analysis.analyzer.zh_ngram_index.tokenizer", JsonData.of("icu_tokenizer"));
settings.put(
"analysis.analyzer.zh_ngram_index.filter",
JsonData.of(List.of("lowercase", "zh_ngram_2_3"))
);
settings.put("analysis.analyzer.zh_search.type", JsonData.of("custom"));
settings.put("analysis.analyzer.zh_search.tokenizer", JsonData.of("icu_tokenizer"));
settings.put(
"analysis.analyzer.zh_search.filter",
JsonData.of(List.of("lowercase", "zh_ngram_2_3"))
);
settings.forEach(builder::customSettings); settings.forEach(builder::customSettings);
return builder; return builder;
} }

View File

@@ -26,6 +26,7 @@ import java.util.stream.Stream;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.opensearch.client.opensearch.OpenSearchClient; import org.opensearch.client.opensearch.OpenSearchClient;
import org.opensearch.client.opensearch._types.FieldValue;
import org.opensearch.client.opensearch._types.query_dsl.TextQueryType; import org.opensearch.client.opensearch._types.query_dsl.TextQueryType;
import org.opensearch.client.opensearch.core.SearchResponse; import org.opensearch.client.opensearch.core.SearchResponse;
import org.opensearch.client.opensearch.core.search.Hit; import org.opensearch.client.opensearch.core.search.Hit;
@@ -229,6 +230,15 @@ public class SearchService {
return openSearchProperties.isEnabled() && openSearchClient.isPresent(); return openSearchProperties.isEnabled() && openSearchClient.isPresent();
} }
// 在类里加上(字段或静态常量都可)
private static final java.util.regex.Pattern HANS_PATTERN = java.util.regex.Pattern.compile(
"\\p{IsHan}"
);
private static boolean containsHan(String s) {
return s != null && HANS_PATTERN.matcher(s).find();
}
private List<SearchResult> searchWithOpenSearch(String keyword) throws IOException { private List<SearchResult> searchWithOpenSearch(String keyword) throws IOException {
var client = openSearchClient.orElse(null); var client = openSearchClient.orElse(null);
if (client == null) return List.of(); if (client == null) return List.of();
@@ -236,8 +246,7 @@ public class SearchService {
final String qRaw = keyword == null ? "" : keyword.trim(); final String qRaw = keyword == null ? "" : keyword.trim();
if (qRaw.isEmpty()) return List.of(); if (qRaw.isEmpty()) return List.of();
final boolean enableWildcard = qRaw.length() >= 2; final boolean hasHan = containsHan(qRaw);
final String qsEscaped = escapeForQueryString(qRaw);
SearchResponse<SearchDocument> resp = client.search( SearchResponse<SearchDocument> resp = client.search(
b -> b ->
@@ -246,105 +255,110 @@ public class SearchService {
.trackTotalHits(t -> t.enabled(true)) .trackTotalHits(t -> t.enabled(true))
.query(qb -> .query(qb ->
qb.bool(bool -> { qb.bool(bool -> {
// 1) 主召回title/content // ---------- 严格层 ----------
// 中文/任意短语(轻微符号/空白扰动)
bool.should(s -> bool.should(s ->
s.multiMatch(mm -> s.matchPhrase(mp -> mp.field("title").query(qRaw).slop(2).boost(6.0f))
mm );
.query(qRaw) bool.should(s ->
.fields("title^3", "title.py^3", "content^2", "content.py^2") s.matchPhrase(mp -> mp.field("content").query(qRaw).slop(2).boost(2.5f))
.type(TextQueryType.BestFields)
.fuzziness("AUTO")
.minimumShouldMatch("70%")
.lenient(true)
)
); );
// 2) 兜底open* 前缀命中 // 结构化等值(.raw
if (enableWildcard) {
bool.should(s ->
s.queryString(qs ->
qs
.query(
"(title:" +
qsEscaped +
"* OR title.py:" +
qsEscaped +
"* OR content:" +
qsEscaped +
"* OR content.py:" +
qsEscaped +
"*)"
)
.analyzeWildcard(true)
)
);
}
// 3) 结构化字段keyword
// term 需要 FieldValue用 lambda 设置 stringValue
bool.should(s -> bool.should(s ->
s.term(t -> s.term(t ->
t t
.field("author") .field("author.raw")
.value(v -> v.stringValue(qRaw)) .value(v -> v.stringValue(qRaw))
.boost(2.0f) .boost(4.0f)
) )
); );
bool.should(s -> bool.should(s ->
s.match(m -> s.term(t ->
m t
.field("author.py") .field("category.raw")
.query(v -> v.stringValue(qRaw)) .value(v -> v.stringValue(qRaw))
.boost(2.0f) .boost(3.0f)
) )
); );
bool.should(s -> bool.should(s ->
s.match(m -> s.term(t ->
m t
.field("category.py") .field("tags.raw")
.query(v -> v.stringValue(qRaw)) .value(v -> v.stringValue(qRaw))
.boost(1.2f) .boost(3.0f)
)
);
bool.should(s ->
s.match(m ->
m
.field("tags.py")
.query(v -> v.stringValue(qRaw))
.boost(1.2f)
) )
); );
if (enableWildcard) { // 拼音短语(严格)
// prefix/wildcard 这里的 value 是 String直接传即可 bool.should(s ->
bool.should(s -> s.prefix(p -> p.field("category").value(qRaw).boost(1.2f))); s.matchPhrase(mp -> mp.field("title.py").query(qRaw).slop(1).boost(4.0f))
bool.should(s -> );
s.wildcard(w -> w.field("category").value("*" + qRaw + "*").boost(1.0f)) bool.should(s ->
); s.matchPhrase(mp -> mp.field("content.py").query(qRaw).slop(1).boost(1.8f))
);
bool.should(s ->
s.matchPhrase(mp -> mp.field("author.py").query(qRaw).slop(1).boost(2.2f))
);
bool.should(s ->
s.matchPhrase(mp -> mp.field("category.py").query(qRaw).slop(1).boost(2.0f))
);
bool.should(s ->
s.matchPhrase(mp -> mp.field("tags.py").query(qRaw).slop(1).boost(2.0f))
);
// ---------- 放宽层(仅当包含中文时启用) ----------
if (hasHan) {
// title.zh
bool.should(s -> bool.should(s ->
s.term(t -> s.match(m ->
t m
.field("tags") .field("title.zh")
.value(v -> v.stringValue(qRaw)) .query(org.opensearch.client.opensearch._types.FieldValue.of(qRaw))
.boost(1.2f) .operator(org.opensearch.client.opensearch._types.query_dsl.Operator.Or)
.minimumShouldMatch("2<-1 3<-1 4<-1 5<-2 6<-2 7<-3")
.boost(3.0f)
) )
); );
// content.zh
bool.should(s -> bool.should(s ->
s.wildcard(w -> w.field("tags").value("*" + qRaw + "*").boost(1.0f)) s.match(m ->
m
.field("content.zh")
.query(org.opensearch.client.opensearch._types.FieldValue.of(qRaw))
.operator(org.opensearch.client.opensearch._types.query_dsl.Operator.Or)
.minimumShouldMatch("2<-1 3<-1 4<-1 5<-2 6<-2 7<-3")
.boost(1.6f)
)
); );
} }
return bool.minimumShouldMatch("1"); return bool.minimumShouldMatch("1");
}) })
) )
.highlight(h -> // ---------- 高亮:允许跨子字段回填 + 匹配字段组 ----------
h .highlight(h -> {
var hb = h
.preTags("<mark>") .preTags("<mark>")
.postTags("</mark>") .postTags("</mark>")
.fields("title", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1)) .requireFieldMatch(false)
.fields("title", f ->
f
.fragmentSize(highlightFragmentSize())
.numberOfFragments(1)
.matchedFields(List.of("title", "title.zh", "title.py"))
)
.fields("content", f ->
f
.fragmentSize(highlightFragmentSize())
.numberOfFragments(1)
.matchedFields(List.of("content", "content.zh", "content.py"))
)
.fields("title.zh", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1))
.fields("content.zh", f ->
f.fragmentSize(highlightFragmentSize()).numberOfFragments(1)
)
.fields("title.py", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1)) .fields("title.py", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1))
.fields("content", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1))
.fields("content.py", f -> .fields("content.py", f ->
f.fragmentSize(highlightFragmentSize()).numberOfFragments(1) f.fragmentSize(highlightFragmentSize()).numberOfFragments(1)
) )
@@ -353,8 +367,9 @@ public class SearchService {
.fields("category", f -> f.numberOfFragments(0)) .fields("category", f -> f.numberOfFragments(0))
.fields("category.py", f -> f.numberOfFragments(0)) .fields("category.py", f -> f.numberOfFragments(0))
.fields("tags", f -> f.numberOfFragments(0)) .fields("tags", f -> f.numberOfFragments(0))
.fields("tags.py", f -> f.numberOfFragments(0)) .fields("tags.py", f -> f.numberOfFragments(0));
) return hb;
})
.size(DEFAULT_OPEN_SEARCH_LIMIT > 0 ? DEFAULT_OPEN_SEARCH_LIMIT : 10), .size(DEFAULT_OPEN_SEARCH_LIMIT > 0 ? DEFAULT_OPEN_SEARCH_LIMIT : 10),
SearchDocument.class SearchDocument.class
); );
@@ -435,8 +450,20 @@ public class SearchService {
return null; return null;
} }
Map<String, List<String>> highlight = hit.highlight(); Map<String, List<String>> highlight = hit.highlight();
String highlightedContent = firstHighlight(highlight, "content", "content.py"); String highlightedContent = firstHighlight(
String highlightedTitle = firstHighlight(highlight, "title", "title.py"); highlight,
"content",
"content.py",
"content.zh",
"content.raw"
);
String highlightedTitle = firstHighlight(
highlight,
"title",
"title.py",
"title.zh",
"title.raw"
);
String highlightedAuthor = firstHighlight(highlight, "author", "author.py"); String highlightedAuthor = firstHighlight(highlight, "author", "author.py");
String highlightedCategory = firstHighlight(highlight, "category", "category.py"); String highlightedCategory = firstHighlight(highlight, "category", "category.py");
boolean highlightTitle = highlightedTitle != null && !highlightedTitle.isBlank(); boolean highlightTitle = highlightedTitle != null && !highlightedTitle.isBlank();
@@ -451,6 +478,7 @@ public class SearchService {
if (snippetHtml == null && highlightTitle) { if (snippetHtml == null && highlightTitle) {
snippetHtml = highlightedTitle; snippetHtml = highlightedTitle;
} }
String snippet = snippetHtml != null && !snippetHtml.isBlank() String snippet = snippetHtml != null && !snippetHtml.isBlank()
? cleanHighlight(snippetHtml) ? cleanHighlight(snippetHtml)
: null; : null;