一、企业级搜索场景与技术选型本项目面向内容社区平台搭建站内检索能力日常业务包含用户关键词检索内容、按匹配相关度智能排序、搜索结果关键词高亮展示、海量数据滚动分页加载、输入联想补全提示等核心场景。平台内容存在发文更新、点赞浏览互动、文章上下架删除等频繁变动对检索时效性、排序合理性与查询并发性能均有较高要求。技术栈整体基于 SpringBoot 框架开发检索业务服务存储检索引擎选用 Elasticsearch 8.x 版本采用官方新版 Java 客户端实现 ES 交互适配新版本 API 语法与调用规范搭配 IK 分词插件处理中文分词场景区分索引分词与检索分词策略精准适配中文语句拆分、语义匹配需求。核心目标宽召回依托多字段匹配策略扩大检索命中范围尽可能召回相关度内容避免优质内容漏搜精准加权结合文本匹配分数与点赞、浏览等业务互动数据综合算分贴合用户偏好实现合理排序高性能深度分页规避传统分页深度查询性能缺陷支持海量数据下流畅滚动翻页实时同步数据库内容新增、编辑、删除、数据统计变更后快速同步至检索索引保障搜索数据一致性二、整体架构设计一图看懂你的代码流程核心模块分工索引定义SearchIndexInitializer数据写入SearchIndexService搜索服务SearchServiceImpl消息消费同步CanalOutboxConsumerSearch三、索引设计Mapping 与分词策略 SearchIndexInitializer索引采用zhihub_content_index名称Mapping 设计严格对齐代码写入的文档字段为title、body、author_nickname等搜索核心字段指定文本类型content_id、publish_time、like_count等字段设为数值 / 关键字类型tags、img_urls定义为数组类型title_suggest单独配置用于搜索建议的补全类型status字段用于软删除状态过滤所有字段类型、长度、是否可检索 / 聚合均贴合业务查询需求分词策略优先选用IK 中文分词器对标题、正文等中文文本采用ik_max_word细粒度分词保障搜索召回对作者昵称、标签等采用ik_smart粗粒度分词提升精准度同时兼容 UTF-8/GB18030 编码解析解决代码中爬取正文的中文乱码问题保证分词准确性。整套设计完全适配代码中的数据回灌、增量写入、软删除逻辑索引结构支撑内容检索、计数排序、搜索建议、状态过滤等核心搜索功能分词策略兼顾中文搜索的召回率与精准度实现业务数据到 Elasticsearch 搜索索引的高效映射。1. 启动时若索引为空进行历史数据回灌分页package com.solis.search.index; import co.elastic.clients.elasticsearch.ElasticsearchClient; import co.elastic.clients.elasticsearch._types.mapping.*; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; /** * 搜索索引初始化应用启动时确保索引与 Mapping 存在。 * 注意title/body 使用 IK 分词器需在 ES 集群安装 analysis-ik 插件。 */ Service RequiredArgsConstructor public class SearchIndexInitializer { private final ElasticsearchClient es; private static final String INDEX zhihub_content_index; /** * 项目一启动就执行方法创建索引存在则跳过 */ PostConstruct public void ensureIndex() { try { //检索指定索引是否存在indices boolean exists es.indices().exists(e - e.index(INDEX)).value(); if (exists) { return; } es.indices().create(c - c.index(INDEX).mappings(m - m //字段名为content_id,类型是长整型的 .properties(content_id, Property.of(p - p.long_(LongNumberProperty.of(b - b)))) .properties(content_type, Property.of(p - p.keyword(KeywordProperty.of(b - b)))) .properties(description, Property.of(p - p.text(TextProperty.of(b - b.analyzer(ik_max_word))))) // IK 分词title 使用 ik_max_word检索使用 ik_smartbody 使用 ik_max_word .properties(title, Property.of(p - p.text(TextProperty.of(b - b.analyzer(ik_max_word).searchAnalyzer(ik_smart))))) .properties(body, Property.of(p - p.text(TextProperty.of(b - b.analyzer(ik_max_word))))) .properties(tags, Property.of(p - p.keyword(KeywordProperty.of(b - b)))) .properties(author_id, Property.of(p - p.long_(LongNumberProperty.of(b - b)))) .properties(author_avatar, Property.of(p - p.keyword(KeywordProperty.of(b - b)))) .properties(author_nickname, Property.of(p - p.keyword(KeywordProperty.of(b - b)))) .properties(author_tag_json, Property.of(p - p.keyword(KeywordProperty.of(b - b)))) .properties(publish_time, Property.of(p - p.date(DateProperty.of(b - b)))) .properties(like_count, Property.of(p - p.integer(IntegerNumberProperty.of(b - b)))) .properties(favorite_count, Property.of(p - p.integer(IntegerNumberProperty.of(b - b)))) .properties(view_count, Property.of(p - p.integer(IntegerNumberProperty.of(b - b)))) .properties(status, Property.of(p - p.keyword(KeywordProperty.of(b - b)))) .properties(img_urls, Property.of(p - p.keyword(KeywordProperty.of(b - b)))) .properties(is_top, Property.of(p - p.keyword(KeywordProperty.of(b - b)))) .properties(title_suggest, Property.of(p - p.completion(CompletionProperty.of(b - b))) ))); } catch (Exception ignored) { // 忽略异常以保证应用启动索引可能由后续写入动态创建但 Mapping 将不完整 } } }四、数据同步全量回灌 增量实时更新 SearchIndexService CanalOutboxConsumerSearch全量数据回灌项目启动自动检测空索引分页读取数据库 → 写入 ES/** * 启动时若索引为空进行历史数据回灌分页。 */ PostConstruct public void ensureBackfill() { try { long cnt es.count(c - c.index(INDEX)).count(); if (cnt 0) return; int limit 500; int offset 0; while (true) { //KnowPostFeedRow:数据库层面的原始数据没有点赞等数据 ListKnowPostFeedRow rows knowPostMapper.listFeedPublic(limit, offset); if (rows null || rows.isEmpty()) { // 没有更多数据结束回灌 break; } for (KnowPostFeedRow r : rows) { upsertKnowPost(r.getId()); } offset rows.size(); } log.info(Search index backfill completed: {} documents, es.count(c - c.index(INDEX)).count()); } catch (Exception e) { log.warn(Search index backfill skipped: {}, e.getMessage()); } }软删内容/** * 软删内容仅更新 statusdeleted同一文档 ID 覆盖写入。 */ public void softDeleteKnowPost(long id) { try { MapString, Object doc new HashMap(); doc.put(content_id, id); doc.put(status, deleted); IndexRequestMapString, Object req IndexRequest.of(b - b .index(INDEX) .id(String.valueOf(id)) .document(doc) .refresh(Refresh.WaitFor) ); es.index(req); } catch (Exception e) { log.error(Index soft delete failed for post {}: {}, id, e.getMessage()); } }/** * 安全拉取正文内容失败返回 null不中断索引流程。 */ private String fetchContentSafe(String url) { if (url null || url.isBlank()) { return null; } try { HttpHeaders headers new HttpHeaders(); //告诉编译器我能接收文本HTMLJSON headers.setAccept(List.of(MediaType.TEXT_HTML, MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON)); //发送get请求把数据通过字节数组获取到,整个http响应 ResponseEntitybyte[] resp http.exchange(url, HttpMethod.GET, new HttpEntity(headers), byte[].class); //从ResponseEntity中获取响应的字节数据 byte[] bytes resp.getBody(); if (bytes null || bytes.length 0) { return null; } //有些接口只在响应头带编码有些只在 HTML 里写 charset //从响应头中获取编码格式 MediaType contentType resp.getHeaders().getContentType(); Charset headerCharset (contentType ! null) ? contentType.getCharset() : null; //读取 HTML 里的 meta charsetUTF-8 标签。 //因为 HTTP 响应头里的编码经常不准、甚至没有所以必须去 HTML 内容里读 meta charsetUTF-8 才最靠谱 Charset metaCharset sniffHtmlCharset(bytes); //自动选择最正确的编码格式。 Charset charset pickCharset(bytes, headerCharset, metaCharset); //把字节数组 → 用正确编码 → 转成字符串正文内容。 return new String(bytes, charset); } catch (Exception e) { return null; } }增量实时同步Kafka Outbox 模式监听数据库变更upsert 更新 /softDelete 软删除正文抓取与编码智能处理解决中文乱码/** * 安全拉取正文内容失败返回 null不中断索引流程。 */ private String fetchContentSafe(String url) { if (url null || url.isBlank()) { return null; } try { HttpHeaders headers new HttpHeaders(); //告诉编译器我能接收文本HTMLJSON headers.setAccept(List.of(MediaType.TEXT_HTML, MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON)); //发送get请求把数据通过字节数组获取到,整个http响应 ResponseEntitybyte[] resp http.exchange(url, HttpMethod.GET, new HttpEntity(headers), byte[].class); //从ResponseEntity中获取响应的字节数据 byte[] bytes resp.getBody(); if (bytes null || bytes.length 0) { return null; } //有些接口只在响应头带编码有些只在 HTML 里写 charset //从响应头中获取编码格式 MediaType contentType resp.getHeaders().getContentType(); Charset headerCharset (contentType ! null) ? contentType.getCharset() : null; //读取 HTML 里的 meta charsetUTF-8 标签。 //因为 HTTP 响应头里的编码经常不准、甚至没有所以必须去 HTML 内容里读 meta charsetUTF-8 才最靠谱 Charset metaCharset sniffHtmlCharset(bytes); //自动选择最正确的编码格式。 Charset charset pickCharset(bytes, headerCharset, metaCharset); //把字节数组 → 用正确编码 → 转成字符串正文内容。 return new String(bytes, charset); } catch (Exception e) { return null; } } /** * 选择正确的编码 * param bytes 字节数组 * param headerCharset 头 * param metaCharset HTML * return 正确的编码 */ private Charset pickCharset(byte[] bytes, Charset headerCharset, Charset metaCharset) { if (metaCharset ! null) { return metaCharset; } //处理缺少header if (headerCharset null) { Charset utf8 StandardCharsets.UTF_8; Charset gb18030 Charset.forName(GB18030); return countReplacementChars(new String(bytes, utf8)) countReplacementChars(new String(bytes, gb18030)) ? utf8 : gb18030; } //纠正错误的编码比如老旧头里携带的错误编码 if (isLikelyWrongCharsetHeader(headerCharset)) { Charset utf8 StandardCharsets.UTF_8; Charset gb18030 Charset.forName(GB18030); int repUtf8 countReplacementChars(new String(bytes, utf8)); int repGb countReplacementChars(new String(bytes, gb18030)); int repHeader countReplacementChars(new String(bytes, headerCharset)); if (repUtf8 repGb repUtf8 repHeader) return utf8; if (repGb repHeader) return gb18030; } return headerCharset; }计数同步点赞 / 收藏 / 浏览量实时写入 ES五、核心检索实现三大高级特性对应 SearchServiceImpl5.1 multi_match 多字段宽召回为实现中文内容的精准且全面的搜索召回采用multi_match多字段联合检索策略将标题 title 权重提升至 3 倍与正文 body 进行跨字段匹配在保证标题关键词优先命中的同时覆盖正文内容实现宽召回。查询结构使用must强制包含用户输入关键词确保结果相关性同时通过filter过滤器实现状态过滤与标签过滤过滤掉已删除、未发布等非法数据并支持按标签精准圈定内容范围既提升查询效率又保证结果合规性。5.2 function_score 业务加权排序仅依靠 Elasticsearch 相关性评分无法满足真实业务的排序需求因此引入function_score实现业务指标加权排序。通过对点赞数 like_count、浏览数 view_count等互动指标进行加权让优质、高热度内容优先展示。为避免高热度数据出现分数爆炸导致排序失衡使用Log1p对数函数对数值进行平滑处理压缩数值差异。最终配置点赞权重 2.0、浏览权重 1.0并采用boostMode分数叠加模式将相关性得分与业务热度得分有机结合实现 “相关 优质” 的综合排序效果。5.3 search_after 游标深度分页传统from size分页在深度翻页时会带来巨大内存与性能损耗无法支撑大规模内容列表查询。因此采用search_after游标分页方案其核心原理是基于上一页最后一条数据的排序值作为游标继续查询避免全量数据扫描性能稳定且可无限翻页。排序规则设计为相关性得分 → 发布时间 → 互动量 → content_id保证分页稳定不重复、不丢数据。为保证游标传输安全与格式兼容性对search_after排序值进行Base64URL 编码与解码并做类型安全处理防止翻页参数被篡改或解析异常提升接口健壮性。六、搜索增强能力高亮 搜索建议关键词高亮title body 高亮片段合并自动生成摘要 snippet搜索建议Completion Suggester基于 title_suggest 实现联想输入前缀匹配、快速返回七、代码细节与工程化最佳实践类型安全处理object 转 String/Long/List异常捕获不影响主流程游标分页编码解码工具方法refreshwait_for 保证实时可搜空值保护、边界判断、性能优化package com.solis.search.index; import co.elastic.clients.elasticsearch.ElasticsearchClient; import co.elastic.clients.elasticsearch._types.Refresh; import co.elastic.clients.elasticsearch.core.IndexRequest; import co.elastic.clients.elasticsearch.core.IndexResponse; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.solis.counter.service.CounterService; import com.solis.knowpost.mapper.KnowPostMapper; import com.solis.knowpost.model.KnowPostDetailRow; import com.solis.knowpost.model.KnowPostFeedRow; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * 搜索索引写入服务负责 upsert/软删 以及首次启动的索引回灌。 */ Service RequiredArgsConstructor public class SearchIndexService { //创建一个日志打印类归属的索引是SearchIndexService private static final Logger log LoggerFactory.getLogger(SearchIndexService.class); private static final String INDEX zhihub_content_index; private final ElasticsearchClient es; private final KnowPostMapper knowPostMapper; private final CounterService counterService; private final ObjectMapper objectMapper; //创建一个http请求工具可以调用别人的接口getpost都行发送网络请求 private final RestTemplate http new RestTemplate(); /** * 启动时若索引为空进行历史数据回灌分页。 */ PostConstruct public void ensureBackfill() { try { long cnt es.count(c - c.index(INDEX)).count(); if (cnt 0) return; int limit 500; int offset 0; while (true) { //KnowPostFeedRow:数据库层面的原始数据没有点赞等数据 ListKnowPostFeedRow rows knowPostMapper.listFeedPublic(limit, offset); if (rows null || rows.isEmpty()) { // 没有更多数据结束回灌 break; } for (KnowPostFeedRow r : rows) { upsertKnowPost(r.getId()); } offset rows.size(); } log.info(Search index backfill completed: {} documents, es.count(c - c.index(INDEX)).count()); } catch (Exception e) { log.warn(Search index backfill skipped: {}, e.getMessage()); } } /** * upsert 内容文档写入基础字段、计数与补全。使用 wait_for 刷新以保障“立即可搜”。 */ public void upsertKnowPost(long id) { try { KnowPostDetailRow row knowPostMapper.findDetailById(id); if (row null) { log.warn(Index upsert skipped: post {} not found, id); return; } MapString, Object doc new HashMap(); doc.put(content_id, row.getId()); doc.put(content_type, row.getType()); doc.put(title, row.getTitle()); doc.put(description, row.getDescription()); doc.put(author_id, row.getCreatorId()); doc.put(author_avatar, row.getAuthorAvatar()); doc.put(author_nickname, row.getAuthorNickname()); doc.put(author_tag_json, row.getAuthorTagJson()); if (row.getPublishTime() ! null) { doc.put(publish_time, row.getPublishTime().toEpochMilli()); } doc.put(status, row.getStatus()); doc.put(tags, parseStringArray(row.getTags())); doc.put(img_urls, parseStringArray(row.getImgUrls())); if (row.getIsTop() ! null) { doc.put(is_top, row.getIsTop()); } // 正文优先拉取 contentUrl失败则使用描述 String body fetchContentSafe(row.getContentUrl()); if (body null || body.isBlank()) { body row.getDescription(); } if (body ! null) { doc.put(body, truncate(body, 4000)); } MapString, Long counts counterService.getCounts(knowpost, String.valueOf(id), List.of(like,fav)); doc.put(like_count, counts.getOrDefault(like, 0L)); doc.put(favorite_count, counts.getOrDefault(fav, 0L)); doc.put(view_count, 0L); if (row.getTitle() ! null !row.getTitle().isBlank()) { doc.put(title_suggest, row.getTitle()); } // 刷新策略wait_for保证写入后即刻可检索 IndexRequestMapString, Object req IndexRequest.of(b - b .index(INDEX) .id(String.valueOf(id)) .document(doc) .refresh(Refresh.WaitFor) ); IndexResponse resp es.index(req); log.info(Indexed post {} result{} version{}, id, resp.result(), resp.version()); } catch (Exception e) { log.error(Index upsert failed for post {}: {}, id, e.getMessage()); } } /** * 软删内容仅更新 statusdeleted同一文档 ID 覆盖写入。 */ public void softDeleteKnowPost(long id) { try { MapString, Object doc new HashMap(); doc.put(content_id, id); doc.put(status, deleted); IndexRequestMapString, Object req IndexRequest.of(b - b .index(INDEX) .id(String.valueOf(id)) .document(doc) .refresh(Refresh.WaitFor) ); es.index(req); } catch (Exception e) { log.error(Index soft delete failed for post {}: {}, id, e.getMessage()); } } /** * 安全拉取正文内容失败返回 null不中断索引流程。 */ private String fetchContentSafe(String url) { if (url null || url.isBlank()) { return null; } try { HttpHeaders headers new HttpHeaders(); //告诉编译器我能接收文本HTMLJSON headers.setAccept(List.of(MediaType.TEXT_HTML, MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON)); //发送get请求把数据通过字节数组获取到,整个http响应 ResponseEntitybyte[] resp http.exchange(url, HttpMethod.GET, new HttpEntity(headers), byte[].class); //从ResponseEntity中获取响应的字节数据 byte[] bytes resp.getBody(); if (bytes null || bytes.length 0) { return null; } //有些接口只在响应头带编码有些只在 HTML 里写 charset //从响应头中获取编码格式 MediaType contentType resp.getHeaders().getContentType(); Charset headerCharset (contentType ! null) ? contentType.getCharset() : null; //读取 HTML 里的 meta charsetUTF-8 标签。 //因为 HTTP 响应头里的编码经常不准、甚至没有所以必须去 HTML 内容里读 meta charsetUTF-8 才最靠谱 Charset metaCharset sniffHtmlCharset(bytes); //自动选择最正确的编码格式。 Charset charset pickCharset(bytes, headerCharset, metaCharset); //把字节数组 → 用正确编码 → 转成字符串正文内容。 return new String(bytes, charset); } catch (Exception e) { return null; } } /** * 选择正确的编码 * param bytes 字节数组 * param headerCharset 头 * param metaCharset HTML * return 正确的编码 */ private Charset pickCharset(byte[] bytes, Charset headerCharset, Charset metaCharset) { if (metaCharset ! null) { return metaCharset; } //处理缺少header if (headerCharset null) { Charset utf8 StandardCharsets.UTF_8; Charset gb18030 Charset.forName(GB18030); return countReplacementChars(new String(bytes, utf8)) countReplacementChars(new String(bytes, gb18030)) ? utf8 : gb18030; } //纠正错误的编码比如老旧头里携带的错误编码 if (isLikelyWrongCharsetHeader(headerCharset)) { Charset utf8 StandardCharsets.UTF_8; Charset gb18030 Charset.forName(GB18030); int repUtf8 countReplacementChars(new String(bytes, utf8)); int repGb countReplacementChars(new String(bytes, gb18030)); int repHeader countReplacementChars(new String(bytes, headerCharset)); if (repUtf8 repGb repUtf8 repHeader) return utf8; if (repGb repHeader) return gb18030; } return headerCharset; } /** * 判断编码是否错误 * param charset 编码 * return 是否错误 */ private boolean isLikelyWrongCharsetHeader(Charset charset) { return StandardCharsets.ISO_8859_1.equals(charset) || StandardCharsets.US_ASCII.equals(charset); } /** * 从 HTML 网页的字节数据里智能嗅探它声明的编码格式UTF-8/GBK 等用来解决中文乱码问题 * param bytes 字节数据 * return 编码 */ private Charset sniffHtmlCharset(byte[] bytes) { //只读取前面一小段 int limit Math.min(bytes.length, 8192); // 用 ISO-8859-1 把字节转成字符串最关键一步,不会破坏字节的编码它把每个字节直接映射成一个字符。 String head new String(bytes, 0, limit, StandardCharsets.ISO_8859_1); Matcher m Pattern.compile(charset\\s*\\s*[\\\]?([a-zA-Z0-9_\\-]), Pattern.CASE_INSENSITIVE).matcher(head); if (!m.find()) { return null; } String cs m.group(1); if (cs null || cs.isBlank()) { return null; } //去掉字符串首尾所有空白strip()更安全 cs cs.trim(); if (utf8.equalsIgnoreCase(cs)) { return StandardCharsets.UTF_8; } if (gbk.equalsIgnoreCase(cs) || gb2312.equalsIgnoreCase(cs) || gb18030.equalsIgnoreCase(cs)) { return Charset.forName(GB18030); } try { return Charset.forName(cs); } catch (Exception e) { return null; } } //统计不同的字符串编码中出现的乱码的个数。乱码\uFFFD private int countReplacementChars(String s) { if (s null || s.isEmpty()) return 0; int cnt 0; for (int i 0; i s.length(); i) { if (s.charAt(i) \uFFFD) cnt; } return cnt; } /** * 截断字符串到最大长度。 */ private String truncate(String s, int max) { if (s null) { return null; } return s.length() max ? s : s.substring(0, max); } /** * 将 JSON 数组字符串解析为 ListString异常返回空列表。 */ private ListString parseStringArray(String json) { if (json null || json.isBlank()) { return Collections.emptyList(); } try { return objectMapper.readValue(json, new TypeReference() { }); } catch (Exception e) { return Collections.emptyList(); } } }
Elasticsearch 高级检索实战:multi_match 宽召回 + function_score 加权排序 + search_after 游标分页落地实现
发布时间:2026/5/22 11:10:26
一、企业级搜索场景与技术选型本项目面向内容社区平台搭建站内检索能力日常业务包含用户关键词检索内容、按匹配相关度智能排序、搜索结果关键词高亮展示、海量数据滚动分页加载、输入联想补全提示等核心场景。平台内容存在发文更新、点赞浏览互动、文章上下架删除等频繁变动对检索时效性、排序合理性与查询并发性能均有较高要求。技术栈整体基于 SpringBoot 框架开发检索业务服务存储检索引擎选用 Elasticsearch 8.x 版本采用官方新版 Java 客户端实现 ES 交互适配新版本 API 语法与调用规范搭配 IK 分词插件处理中文分词场景区分索引分词与检索分词策略精准适配中文语句拆分、语义匹配需求。核心目标宽召回依托多字段匹配策略扩大检索命中范围尽可能召回相关度内容避免优质内容漏搜精准加权结合文本匹配分数与点赞、浏览等业务互动数据综合算分贴合用户偏好实现合理排序高性能深度分页规避传统分页深度查询性能缺陷支持海量数据下流畅滚动翻页实时同步数据库内容新增、编辑、删除、数据统计变更后快速同步至检索索引保障搜索数据一致性二、整体架构设计一图看懂你的代码流程核心模块分工索引定义SearchIndexInitializer数据写入SearchIndexService搜索服务SearchServiceImpl消息消费同步CanalOutboxConsumerSearch三、索引设计Mapping 与分词策略 SearchIndexInitializer索引采用zhihub_content_index名称Mapping 设计严格对齐代码写入的文档字段为title、body、author_nickname等搜索核心字段指定文本类型content_id、publish_time、like_count等字段设为数值 / 关键字类型tags、img_urls定义为数组类型title_suggest单独配置用于搜索建议的补全类型status字段用于软删除状态过滤所有字段类型、长度、是否可检索 / 聚合均贴合业务查询需求分词策略优先选用IK 中文分词器对标题、正文等中文文本采用ik_max_word细粒度分词保障搜索召回对作者昵称、标签等采用ik_smart粗粒度分词提升精准度同时兼容 UTF-8/GB18030 编码解析解决代码中爬取正文的中文乱码问题保证分词准确性。整套设计完全适配代码中的数据回灌、增量写入、软删除逻辑索引结构支撑内容检索、计数排序、搜索建议、状态过滤等核心搜索功能分词策略兼顾中文搜索的召回率与精准度实现业务数据到 Elasticsearch 搜索索引的高效映射。1. 启动时若索引为空进行历史数据回灌分页package com.solis.search.index; import co.elastic.clients.elasticsearch.ElasticsearchClient; import co.elastic.clients.elasticsearch._types.mapping.*; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; /** * 搜索索引初始化应用启动时确保索引与 Mapping 存在。 * 注意title/body 使用 IK 分词器需在 ES 集群安装 analysis-ik 插件。 */ Service RequiredArgsConstructor public class SearchIndexInitializer { private final ElasticsearchClient es; private static final String INDEX zhihub_content_index; /** * 项目一启动就执行方法创建索引存在则跳过 */ PostConstruct public void ensureIndex() { try { //检索指定索引是否存在indices boolean exists es.indices().exists(e - e.index(INDEX)).value(); if (exists) { return; } es.indices().create(c - c.index(INDEX).mappings(m - m //字段名为content_id,类型是长整型的 .properties(content_id, Property.of(p - p.long_(LongNumberProperty.of(b - b)))) .properties(content_type, Property.of(p - p.keyword(KeywordProperty.of(b - b)))) .properties(description, Property.of(p - p.text(TextProperty.of(b - b.analyzer(ik_max_word))))) // IK 分词title 使用 ik_max_word检索使用 ik_smartbody 使用 ik_max_word .properties(title, Property.of(p - p.text(TextProperty.of(b - b.analyzer(ik_max_word).searchAnalyzer(ik_smart))))) .properties(body, Property.of(p - p.text(TextProperty.of(b - b.analyzer(ik_max_word))))) .properties(tags, Property.of(p - p.keyword(KeywordProperty.of(b - b)))) .properties(author_id, Property.of(p - p.long_(LongNumberProperty.of(b - b)))) .properties(author_avatar, Property.of(p - p.keyword(KeywordProperty.of(b - b)))) .properties(author_nickname, Property.of(p - p.keyword(KeywordProperty.of(b - b)))) .properties(author_tag_json, Property.of(p - p.keyword(KeywordProperty.of(b - b)))) .properties(publish_time, Property.of(p - p.date(DateProperty.of(b - b)))) .properties(like_count, Property.of(p - p.integer(IntegerNumberProperty.of(b - b)))) .properties(favorite_count, Property.of(p - p.integer(IntegerNumberProperty.of(b - b)))) .properties(view_count, Property.of(p - p.integer(IntegerNumberProperty.of(b - b)))) .properties(status, Property.of(p - p.keyword(KeywordProperty.of(b - b)))) .properties(img_urls, Property.of(p - p.keyword(KeywordProperty.of(b - b)))) .properties(is_top, Property.of(p - p.keyword(KeywordProperty.of(b - b)))) .properties(title_suggest, Property.of(p - p.completion(CompletionProperty.of(b - b))) ))); } catch (Exception ignored) { // 忽略异常以保证应用启动索引可能由后续写入动态创建但 Mapping 将不完整 } } }四、数据同步全量回灌 增量实时更新 SearchIndexService CanalOutboxConsumerSearch全量数据回灌项目启动自动检测空索引分页读取数据库 → 写入 ES/** * 启动时若索引为空进行历史数据回灌分页。 */ PostConstruct public void ensureBackfill() { try { long cnt es.count(c - c.index(INDEX)).count(); if (cnt 0) return; int limit 500; int offset 0; while (true) { //KnowPostFeedRow:数据库层面的原始数据没有点赞等数据 ListKnowPostFeedRow rows knowPostMapper.listFeedPublic(limit, offset); if (rows null || rows.isEmpty()) { // 没有更多数据结束回灌 break; } for (KnowPostFeedRow r : rows) { upsertKnowPost(r.getId()); } offset rows.size(); } log.info(Search index backfill completed: {} documents, es.count(c - c.index(INDEX)).count()); } catch (Exception e) { log.warn(Search index backfill skipped: {}, e.getMessage()); } }软删内容/** * 软删内容仅更新 statusdeleted同一文档 ID 覆盖写入。 */ public void softDeleteKnowPost(long id) { try { MapString, Object doc new HashMap(); doc.put(content_id, id); doc.put(status, deleted); IndexRequestMapString, Object req IndexRequest.of(b - b .index(INDEX) .id(String.valueOf(id)) .document(doc) .refresh(Refresh.WaitFor) ); es.index(req); } catch (Exception e) { log.error(Index soft delete failed for post {}: {}, id, e.getMessage()); } }/** * 安全拉取正文内容失败返回 null不中断索引流程。 */ private String fetchContentSafe(String url) { if (url null || url.isBlank()) { return null; } try { HttpHeaders headers new HttpHeaders(); //告诉编译器我能接收文本HTMLJSON headers.setAccept(List.of(MediaType.TEXT_HTML, MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON)); //发送get请求把数据通过字节数组获取到,整个http响应 ResponseEntitybyte[] resp http.exchange(url, HttpMethod.GET, new HttpEntity(headers), byte[].class); //从ResponseEntity中获取响应的字节数据 byte[] bytes resp.getBody(); if (bytes null || bytes.length 0) { return null; } //有些接口只在响应头带编码有些只在 HTML 里写 charset //从响应头中获取编码格式 MediaType contentType resp.getHeaders().getContentType(); Charset headerCharset (contentType ! null) ? contentType.getCharset() : null; //读取 HTML 里的 meta charsetUTF-8 标签。 //因为 HTTP 响应头里的编码经常不准、甚至没有所以必须去 HTML 内容里读 meta charsetUTF-8 才最靠谱 Charset metaCharset sniffHtmlCharset(bytes); //自动选择最正确的编码格式。 Charset charset pickCharset(bytes, headerCharset, metaCharset); //把字节数组 → 用正确编码 → 转成字符串正文内容。 return new String(bytes, charset); } catch (Exception e) { return null; } }增量实时同步Kafka Outbox 模式监听数据库变更upsert 更新 /softDelete 软删除正文抓取与编码智能处理解决中文乱码/** * 安全拉取正文内容失败返回 null不中断索引流程。 */ private String fetchContentSafe(String url) { if (url null || url.isBlank()) { return null; } try { HttpHeaders headers new HttpHeaders(); //告诉编译器我能接收文本HTMLJSON headers.setAccept(List.of(MediaType.TEXT_HTML, MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON)); //发送get请求把数据通过字节数组获取到,整个http响应 ResponseEntitybyte[] resp http.exchange(url, HttpMethod.GET, new HttpEntity(headers), byte[].class); //从ResponseEntity中获取响应的字节数据 byte[] bytes resp.getBody(); if (bytes null || bytes.length 0) { return null; } //有些接口只在响应头带编码有些只在 HTML 里写 charset //从响应头中获取编码格式 MediaType contentType resp.getHeaders().getContentType(); Charset headerCharset (contentType ! null) ? contentType.getCharset() : null; //读取 HTML 里的 meta charsetUTF-8 标签。 //因为 HTTP 响应头里的编码经常不准、甚至没有所以必须去 HTML 内容里读 meta charsetUTF-8 才最靠谱 Charset metaCharset sniffHtmlCharset(bytes); //自动选择最正确的编码格式。 Charset charset pickCharset(bytes, headerCharset, metaCharset); //把字节数组 → 用正确编码 → 转成字符串正文内容。 return new String(bytes, charset); } catch (Exception e) { return null; } } /** * 选择正确的编码 * param bytes 字节数组 * param headerCharset 头 * param metaCharset HTML * return 正确的编码 */ private Charset pickCharset(byte[] bytes, Charset headerCharset, Charset metaCharset) { if (metaCharset ! null) { return metaCharset; } //处理缺少header if (headerCharset null) { Charset utf8 StandardCharsets.UTF_8; Charset gb18030 Charset.forName(GB18030); return countReplacementChars(new String(bytes, utf8)) countReplacementChars(new String(bytes, gb18030)) ? utf8 : gb18030; } //纠正错误的编码比如老旧头里携带的错误编码 if (isLikelyWrongCharsetHeader(headerCharset)) { Charset utf8 StandardCharsets.UTF_8; Charset gb18030 Charset.forName(GB18030); int repUtf8 countReplacementChars(new String(bytes, utf8)); int repGb countReplacementChars(new String(bytes, gb18030)); int repHeader countReplacementChars(new String(bytes, headerCharset)); if (repUtf8 repGb repUtf8 repHeader) return utf8; if (repGb repHeader) return gb18030; } return headerCharset; }计数同步点赞 / 收藏 / 浏览量实时写入 ES五、核心检索实现三大高级特性对应 SearchServiceImpl5.1 multi_match 多字段宽召回为实现中文内容的精准且全面的搜索召回采用multi_match多字段联合检索策略将标题 title 权重提升至 3 倍与正文 body 进行跨字段匹配在保证标题关键词优先命中的同时覆盖正文内容实现宽召回。查询结构使用must强制包含用户输入关键词确保结果相关性同时通过filter过滤器实现状态过滤与标签过滤过滤掉已删除、未发布等非法数据并支持按标签精准圈定内容范围既提升查询效率又保证结果合规性。5.2 function_score 业务加权排序仅依靠 Elasticsearch 相关性评分无法满足真实业务的排序需求因此引入function_score实现业务指标加权排序。通过对点赞数 like_count、浏览数 view_count等互动指标进行加权让优质、高热度内容优先展示。为避免高热度数据出现分数爆炸导致排序失衡使用Log1p对数函数对数值进行平滑处理压缩数值差异。最终配置点赞权重 2.0、浏览权重 1.0并采用boostMode分数叠加模式将相关性得分与业务热度得分有机结合实现 “相关 优质” 的综合排序效果。5.3 search_after 游标深度分页传统from size分页在深度翻页时会带来巨大内存与性能损耗无法支撑大规模内容列表查询。因此采用search_after游标分页方案其核心原理是基于上一页最后一条数据的排序值作为游标继续查询避免全量数据扫描性能稳定且可无限翻页。排序规则设计为相关性得分 → 发布时间 → 互动量 → content_id保证分页稳定不重复、不丢数据。为保证游标传输安全与格式兼容性对search_after排序值进行Base64URL 编码与解码并做类型安全处理防止翻页参数被篡改或解析异常提升接口健壮性。六、搜索增强能力高亮 搜索建议关键词高亮title body 高亮片段合并自动生成摘要 snippet搜索建议Completion Suggester基于 title_suggest 实现联想输入前缀匹配、快速返回七、代码细节与工程化最佳实践类型安全处理object 转 String/Long/List异常捕获不影响主流程游标分页编码解码工具方法refreshwait_for 保证实时可搜空值保护、边界判断、性能优化package com.solis.search.index; import co.elastic.clients.elasticsearch.ElasticsearchClient; import co.elastic.clients.elasticsearch._types.Refresh; import co.elastic.clients.elasticsearch.core.IndexRequest; import co.elastic.clients.elasticsearch.core.IndexResponse; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.solis.counter.service.CounterService; import com.solis.knowpost.mapper.KnowPostMapper; import com.solis.knowpost.model.KnowPostDetailRow; import com.solis.knowpost.model.KnowPostFeedRow; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * 搜索索引写入服务负责 upsert/软删 以及首次启动的索引回灌。 */ Service RequiredArgsConstructor public class SearchIndexService { //创建一个日志打印类归属的索引是SearchIndexService private static final Logger log LoggerFactory.getLogger(SearchIndexService.class); private static final String INDEX zhihub_content_index; private final ElasticsearchClient es; private final KnowPostMapper knowPostMapper; private final CounterService counterService; private final ObjectMapper objectMapper; //创建一个http请求工具可以调用别人的接口getpost都行发送网络请求 private final RestTemplate http new RestTemplate(); /** * 启动时若索引为空进行历史数据回灌分页。 */ PostConstruct public void ensureBackfill() { try { long cnt es.count(c - c.index(INDEX)).count(); if (cnt 0) return; int limit 500; int offset 0; while (true) { //KnowPostFeedRow:数据库层面的原始数据没有点赞等数据 ListKnowPostFeedRow rows knowPostMapper.listFeedPublic(limit, offset); if (rows null || rows.isEmpty()) { // 没有更多数据结束回灌 break; } for (KnowPostFeedRow r : rows) { upsertKnowPost(r.getId()); } offset rows.size(); } log.info(Search index backfill completed: {} documents, es.count(c - c.index(INDEX)).count()); } catch (Exception e) { log.warn(Search index backfill skipped: {}, e.getMessage()); } } /** * upsert 内容文档写入基础字段、计数与补全。使用 wait_for 刷新以保障“立即可搜”。 */ public void upsertKnowPost(long id) { try { KnowPostDetailRow row knowPostMapper.findDetailById(id); if (row null) { log.warn(Index upsert skipped: post {} not found, id); return; } MapString, Object doc new HashMap(); doc.put(content_id, row.getId()); doc.put(content_type, row.getType()); doc.put(title, row.getTitle()); doc.put(description, row.getDescription()); doc.put(author_id, row.getCreatorId()); doc.put(author_avatar, row.getAuthorAvatar()); doc.put(author_nickname, row.getAuthorNickname()); doc.put(author_tag_json, row.getAuthorTagJson()); if (row.getPublishTime() ! null) { doc.put(publish_time, row.getPublishTime().toEpochMilli()); } doc.put(status, row.getStatus()); doc.put(tags, parseStringArray(row.getTags())); doc.put(img_urls, parseStringArray(row.getImgUrls())); if (row.getIsTop() ! null) { doc.put(is_top, row.getIsTop()); } // 正文优先拉取 contentUrl失败则使用描述 String body fetchContentSafe(row.getContentUrl()); if (body null || body.isBlank()) { body row.getDescription(); } if (body ! null) { doc.put(body, truncate(body, 4000)); } MapString, Long counts counterService.getCounts(knowpost, String.valueOf(id), List.of(like,fav)); doc.put(like_count, counts.getOrDefault(like, 0L)); doc.put(favorite_count, counts.getOrDefault(fav, 0L)); doc.put(view_count, 0L); if (row.getTitle() ! null !row.getTitle().isBlank()) { doc.put(title_suggest, row.getTitle()); } // 刷新策略wait_for保证写入后即刻可检索 IndexRequestMapString, Object req IndexRequest.of(b - b .index(INDEX) .id(String.valueOf(id)) .document(doc) .refresh(Refresh.WaitFor) ); IndexResponse resp es.index(req); log.info(Indexed post {} result{} version{}, id, resp.result(), resp.version()); } catch (Exception e) { log.error(Index upsert failed for post {}: {}, id, e.getMessage()); } } /** * 软删内容仅更新 statusdeleted同一文档 ID 覆盖写入。 */ public void softDeleteKnowPost(long id) { try { MapString, Object doc new HashMap(); doc.put(content_id, id); doc.put(status, deleted); IndexRequestMapString, Object req IndexRequest.of(b - b .index(INDEX) .id(String.valueOf(id)) .document(doc) .refresh(Refresh.WaitFor) ); es.index(req); } catch (Exception e) { log.error(Index soft delete failed for post {}: {}, id, e.getMessage()); } } /** * 安全拉取正文内容失败返回 null不中断索引流程。 */ private String fetchContentSafe(String url) { if (url null || url.isBlank()) { return null; } try { HttpHeaders headers new HttpHeaders(); //告诉编译器我能接收文本HTMLJSON headers.setAccept(List.of(MediaType.TEXT_HTML, MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON)); //发送get请求把数据通过字节数组获取到,整个http响应 ResponseEntitybyte[] resp http.exchange(url, HttpMethod.GET, new HttpEntity(headers), byte[].class); //从ResponseEntity中获取响应的字节数据 byte[] bytes resp.getBody(); if (bytes null || bytes.length 0) { return null; } //有些接口只在响应头带编码有些只在 HTML 里写 charset //从响应头中获取编码格式 MediaType contentType resp.getHeaders().getContentType(); Charset headerCharset (contentType ! null) ? contentType.getCharset() : null; //读取 HTML 里的 meta charsetUTF-8 标签。 //因为 HTTP 响应头里的编码经常不准、甚至没有所以必须去 HTML 内容里读 meta charsetUTF-8 才最靠谱 Charset metaCharset sniffHtmlCharset(bytes); //自动选择最正确的编码格式。 Charset charset pickCharset(bytes, headerCharset, metaCharset); //把字节数组 → 用正确编码 → 转成字符串正文内容。 return new String(bytes, charset); } catch (Exception e) { return null; } } /** * 选择正确的编码 * param bytes 字节数组 * param headerCharset 头 * param metaCharset HTML * return 正确的编码 */ private Charset pickCharset(byte[] bytes, Charset headerCharset, Charset metaCharset) { if (metaCharset ! null) { return metaCharset; } //处理缺少header if (headerCharset null) { Charset utf8 StandardCharsets.UTF_8; Charset gb18030 Charset.forName(GB18030); return countReplacementChars(new String(bytes, utf8)) countReplacementChars(new String(bytes, gb18030)) ? utf8 : gb18030; } //纠正错误的编码比如老旧头里携带的错误编码 if (isLikelyWrongCharsetHeader(headerCharset)) { Charset utf8 StandardCharsets.UTF_8; Charset gb18030 Charset.forName(GB18030); int repUtf8 countReplacementChars(new String(bytes, utf8)); int repGb countReplacementChars(new String(bytes, gb18030)); int repHeader countReplacementChars(new String(bytes, headerCharset)); if (repUtf8 repGb repUtf8 repHeader) return utf8; if (repGb repHeader) return gb18030; } return headerCharset; } /** * 判断编码是否错误 * param charset 编码 * return 是否错误 */ private boolean isLikelyWrongCharsetHeader(Charset charset) { return StandardCharsets.ISO_8859_1.equals(charset) || StandardCharsets.US_ASCII.equals(charset); } /** * 从 HTML 网页的字节数据里智能嗅探它声明的编码格式UTF-8/GBK 等用来解决中文乱码问题 * param bytes 字节数据 * return 编码 */ private Charset sniffHtmlCharset(byte[] bytes) { //只读取前面一小段 int limit Math.min(bytes.length, 8192); // 用 ISO-8859-1 把字节转成字符串最关键一步,不会破坏字节的编码它把每个字节直接映射成一个字符。 String head new String(bytes, 0, limit, StandardCharsets.ISO_8859_1); Matcher m Pattern.compile(charset\\s*\\s*[\\\]?([a-zA-Z0-9_\\-]), Pattern.CASE_INSENSITIVE).matcher(head); if (!m.find()) { return null; } String cs m.group(1); if (cs null || cs.isBlank()) { return null; } //去掉字符串首尾所有空白strip()更安全 cs cs.trim(); if (utf8.equalsIgnoreCase(cs)) { return StandardCharsets.UTF_8; } if (gbk.equalsIgnoreCase(cs) || gb2312.equalsIgnoreCase(cs) || gb18030.equalsIgnoreCase(cs)) { return Charset.forName(GB18030); } try { return Charset.forName(cs); } catch (Exception e) { return null; } } //统计不同的字符串编码中出现的乱码的个数。乱码\uFFFD private int countReplacementChars(String s) { if (s null || s.isEmpty()) return 0; int cnt 0; for (int i 0; i s.length(); i) { if (s.charAt(i) \uFFFD) cnt; } return cnt; } /** * 截断字符串到最大长度。 */ private String truncate(String s, int max) { if (s null) { return null; } return s.length() max ? s : s.substring(0, max); } /** * 将 JSON 数组字符串解析为 ListString异常返回空列表。 */ private ListString parseStringArray(String json) { if (json null || json.isBlank()) { return Collections.emptyList(); } try { return objectMapper.readValue(json, new TypeReference() { }); } catch (Exception e) { return Collections.emptyList(); } } }