检索网站从选型到落地:基于Elasticsearch的站内搜索实践
做检索类网站很多人的第一反应是“不就是个搜索框嘛”真正动手踩过坑的人才知道这一行字背后藏着分词、索引、排序、性能、相关性一大堆问题。我自己前前后后做过几个检索项目从最开始的数据库模糊查询到后来专门搭独立的站内检索引擎中间走弯路踩坑的经验不少。这篇就用一个“检索网站”的通用场景把从方案选型到落地实现的完整思路拆开讲清楚希望能给正在做类似功能的朋友一些参考。1. 项目概述与核心思路1.1 这个项目到底要解决什么问题检索网站这个标题听起来很宽泛但落到实际需求上通常就两类场景一类是站在使用者的角度想知道一个检索网站怎么把人、文档、商品、内容快速找出来另一类是站在开发者的角度面对一堆数据怎么样给用户提供一个又快又准的搜索框。我这次聊的主要是后者毕竟只有把后端的逻辑理清了前端那个搜索框才算真正“活”了。咱们拆开看一个检索网站的核心诉求无非三点查得全、查得准、查得快。“查得全”意味着数据不能丢不管用户输入的是完整词还是简写都能把相关内容捞出来“查得准”要求排序合理最相关的结果排在最前面“查得快”则是在数据量涨到几十万上百万条之后搜索响应依然能稳定在几百毫秒以内。这三者往往互相牵制做检索项目的大多数时间其实就是在权衡这三点。1.2 方案选型从数据库LIKE到专用检索引擎很多人第一个想到的实现方式是在数据库里写LIKE %关键词%或者用instr、locate这类函数做模糊匹配。数据量小的时候确实够用我见过不少后台管理系统就是这么干的几千条数据跑起来毫无压力。但一旦数据量上来或者查询并发一高这条路就堵死了——全表扫描、索引失效、数据库 CPU 被打满这些都是必然结果。后来不少人会选择 MySQL 自带的全文索引也就是FULLTEXT。这个方案比LIKE进步不少尤其是配合ngram分词器处理中文时能撑起百万级别数据的简单搜索。但它的问题也很明显分词能力弱、排序算法简单、扩容困难想根据业务做个性化的排序规则基本要折腾很久。专用检索引擎是另一条路也是我觉得做正规检索网站应该走的路。像 Elasticsearch、OpenSearch 这类基于倒排索引的搜索引擎天生就是干这个的。它们把数据预处理成倒排索引查询的时候直接并行扫多个分片再配合丰富的查询语法和打分机制能在海量数据下保持稳定的检索体验。我第一次把一套 500 万条数据的检索从 MySQL 迁移到 Elasticsearch 时搜索耗时从 3 秒多降到了 200 毫秒以内那种差距是肉眼可见的。方案选型上我个人的建议是三档数据量在几万以内业务简单直接用数据库模糊查询就行没必要为了“搜索引擎”四个字引入一套重型组件数据量到几十万甚至百万级且对检索体验有要求优先考虑 Elasticsearch 或 OpenSearch如果团队已经具备较强的底层研发能力且业务场景极其特殊再考虑基于 Lucene 自研封装。大多数做“检索网站”的团队落到最后选的还是 Elasticsearch生态成熟、资料多、排错方便这也是下面内容里我默认使用的技术栈。2. 核心细节解析与实操要点2.1 数据建模与分词逻辑检索网站里最容易被忽略、又最容易翻车的环节就是数据建模。很多人的第一反应是“把数据库表原样同步过来不就行了”但你想想如果一条商品记录里包含标题、描述、品牌、规格、价格、库存这些东西在检索系统里承担的职责完全不一样有的需要全文检索有的只需要精确匹配有的还要支持范围过滤根本不该放进同一个字段里统一处理。我在项目里通常的做法是全文检索字段比如标题、正文使用text类型配合合适的分词器精确匹配字段比如品牌名、商品编号使用keyword类型方便做 filter 和聚合数值字段比如价格、库存、发布时间使用long、integer、date类型用于范围过滤和排序冗余存储字段用于在搜索结果里直接展示避免查询时回表。这么做的好处是不同类型的查询需求可以通过不同的字段组合完成字段时间不会互相污染。比如搜索“iPhone 15”的时候全文检索字段会匹配到包含这个词的标题而如果你顺便过滤品牌为“Apple”keyword 字段能保证过滤的准确性两者互不干扰。分词是中文检索的核心难点。英文单词天然用空格隔开分词器随便切都没什么问题中文没有明显边界“北京烤鸭”可以被切成“北京”“烤鸭”也可以被切成“北”“京”“烤”“鸭”不同的切法直接决定搜索结果是否合理。实际项目中我常用两种分词器IK 分词器支持自定义词典和停用词对中文场景非常友好是大多数项目的首选pinyin 分词器用于支持拼音搜索比如用户输入“beijingkaoya”也能找到“北京烤鸭”对某些 C 端产品体验提升很大。分词器不是越细越好。太细会导致索引体积膨胀查询噪声大太粗又会漏召回。建议上线前拿一批真实搜索词做分词测试然后用搜索结果反推调整词典和分词策略。2.2 倒排索引原理与存储设计检索引擎之所以比数据库快核心在于数据结构完全不同。数据库的 B 树索引本质上是在有序结构上做查找适合等值、范围查询而检索引擎用的是倒排索引简单说就是建立“词条—文档”的映射关系。举一个最直白的例子。有三句话文档一“苹果发布新款手机”文档二“苹果手机降价”文档三“香蕉和苹果哪个热量高”倒排索引会把词条“苹果”映射到文档一、二、三“手机”映射到文档一、二“降价”映射到文档二。当用户搜索“苹果手机”的时候搜索引擎只需要查这些词条对应的文档列表然后取交集、算相关性而不需要去全表扫描三句话。这个数据结构层面的差异就是检索网站性能碾压普通数据库模糊查询的根本原因。存储设计上还有一个容易被忽略的点除了倒排索引检索引擎通常还要存储原始文档_source和正排索引doc values。_source用于结果展示doc values 用于排序和聚合分析。如果业务里只需要展示标题和摘要完全可以在映射阶段关闭_source的大字段存储或者利用includes/excludes做字段裁剪减少存储开销。另外索引的分片数和副本数不是越大越好。分片数是索引的“并行度”太多会导致小请求的开销变大太少又无法利用多节点能力副本数影响高可用和读吞吐但副本太多会占用大量磁盘。我的经验是每个分片容量控制在 30GB 到 50GB 左右比较合适副本数看读压力来定通常 1 个副本起步读压力大再往上加。3. 实操过程与核心环节实现3.1 环境准备与索引初始化检索网站的后端核心部分是搜索引擎的接入。这里以 Elasticsearch 为例先把基础环境跑起来。我建议本地开发直接用 Docker 起单节点干净又便于清理。docker network create search-net docker run -d --name elasticsearch --net search-net \ -p 9200:9200 -p 9300:9300 \ -e discovery.typesingle-node \ -e xpack.security.enabledfalse \ -e ES_JAVA_OPTS-Xms1g -Xmx1g \ docker.elastic.co/elasticsearch/elasticsearch:8.11.0启动完之后用curl localhost:9200确认服务在线。这里要注意Java 堆内存设置不要盲目调大我给过 4G 堆导致机器卡死的教训在小内存机器上 1G 到 2G 完全够用。堆内存大的同时要给操作系统留足文件缓存的空间Elasticsearch 很依赖 OS 的 page cache。接着创建索引并指定好字段类型和分词器。比如做一个文章检索索引结构可以这样设计PUT /articles { settings: { number_of_shards: 3, number_of_replicas: 1, analysis: { analyzer: { ik_pinyin_analyzer: { type: custom, tokenizer: ik_max_word, filter: [pinyin_filter] } }, filter: { pinyin_filter: { type: pinyin, keep_first_letter: true, keep_separate_first_letter: false, keep_full_pinyin: true, keep_joined_full_pinyin: true } } } }, mappings: { properties: { id: { type: keyword }, title: { type: text, analyzer: ik_max_word, search_analyzer: ik_smart, fields: { pinyin: { type: text, analyzer: ik_pinyin_analyzer } } }, content: { type: text, analyzer: ik_max_word, search_analyzer: ik_smart }, category: { type: keyword }, publish_time: { type: date }, views: { type: integer } } } }索引创建好后要把业务数据同步进去。常见的同步方式有几种全量同步一次性导入历史数据适合冷启动增量同步通过监听数据库 binlog 变更事件实时更新索引数据适合后续日常维护定时任务同步每隔一段时间跑一次增量拉取实现简单但实时性稍差。我刚做第一版检索网站的时候图省事全量同步完就以为结束了结果业务数据一更新搜索就不准了。后来老老实实接了一套基于 binlog 的增量管道才彻底解决数据一致性问题。增量同步组件可以用现成的 Canal 加消息队列也可以用 Logstash 的 JDBC 插件定时拉取看团队技术栈来选。3.2 检索接口的实现与参数调优数据进索引之后检索接口就需要把用户输入转成搜索引擎查询语句。这里有几个关键参数值得重点讲。先看一个最基本的多字段全文检索查询POST /articles/_search { query: { multi_match: { query: 新品手机, fields: [title^3, content], type: best_fields } }, highlight: { fields: { title: {}, content: {} } } }title^3的意思是标题字段的权重是内容字段的 3 倍这是很常用的手段——标题里命中的结果相关性权重自然要高。best_fields表示取多个字段中匹配度最高的那个字段得分作为最终得分适合“任意一个字段命中就算命中”的场景。如果希望多个字段都命中才能加分可以考虑cross_fields。排序也是检索网站体验的关键。默认情况下 Elasticsearch 按照相关度打分排序但光靠打分往往不够。比如文章检索用户可能希望热门文章排前面商品检索用户可能希望按价格筛选后按销量排序。实现方式是在查询里加 sortPOST /articles/_search { query: { match_all: {} }, sort: [ { views: { order: desc } }, { publish_time: { order: desc } } ] }但要注意一旦指定了 sortElasticsearch 就不再计算相关度得分了。这在某些场景下有问题比如用户搜“苹果手机”你按浏览量排出来一堆“苹果电脑”的记录这就很糟。通常的做法是对排序字段做折中比如先按相关性得分过滤掉明显不相关的结果再在候选集内按浏览量排序或者使用function_score把浏览量作为一个加分项融合进相关性评分里而不是完全替换掉评分。搜索接口还要考虑分页深翻的问题。大多数用户只会看前几页所以深分页不是主要场景。但如果专门做后台数据查询一次性拉几万条数据就得考虑search_after或scroll机制前者适合实时游标翻页后者适合全量导出场景。试想一下前端搜索框每敲一个字就发一次请求后端如果每个请求都做全链路查询负载很难扛。实际项目中我加的优化手段有三个一是对高频词做查询结果缓存二是基于 ik 分词的search_analyzer降低短语匹配的噪声三是对日志里超过 1 秒的慢查询做单独追踪定位是分词耗时还是分片查询耗时。4. 常见问题与排查技巧实录4.1 结果不准、排序异常的排查检索网站上线后遇到最多的问题就是用户反馈“搜不到想要的”“排在前面的不是我要的”。这种问题通常不是代码 bug而是相关性的调优问题。我总结了几类高频场景和处理办法。场景一搜索词分词不符合预期。用户搜“MacBook Air”结果被分词器拆成“mac”“book”“air”每个词单独匹配匹配范围变宽了得分反而不够集中。解决思路是用match_phrase做短语匹配约束或者自定义同义词词典把“macbook”和“mac book”归并。这需要观察搜索结果看命中的关键词到底来自哪个分词片段可以用_analyze接口直接验证。POST /_analyze { analyzer: ik_max_word, text: MacBook Air }场景二相关性权重设置不合理。搜索结果里标题完全不相关、正文里恰好出现过一次“手机”的文章排到了前面多半是字段权重没有拉开。把title^3提高到title^5甚至给 tag 字段也加一些权重能明显改善这种情况。场景三过滤条件挤掉了正确结果。用户明明搜了“手机”却因为附加了“库存大于0”的 filter 把一些数据过滤掉了导致结果稀疏。这时候要复盘 filter 条件是否过严或者考虑把库存状态从 filter 挪到评分里作为降权因素而不是直接过滤。场景四排序字段把相关度完全干掉了。之前聊过指定 sort 后得分被忽略的问题。项目里我们后来统一改成了function_score让浏览量、发布时间作为平滑的加分函数参与评分既保留了相关度的主导地位又兼顾了内容热度。做法上可以先把explain打开看单条结果的具体得分拆解POST /articles/_search { explain: true, query: { match: { title: 手机 } } }explain输出里能看到每个词条的 tf-idf 得分、字段长度归一化、协调因子等排查“为什么这个结果得分高”特别有用。不过 production 环境要谨慎开启因为它会大幅增加查询开销属于调优期的“手术刀”不是常态手段。4.2 性能退化与索引维护经验检索网站在数据量持续增长后另一个高发问题是性能退化。特征很明显查询从几十毫秒涨到几百毫秒写入吞吐下降甚至集群状态变红。先说查询变慢。第一件要做的事是看慢查询日志把查询耗时超过阈值的请求抓出来比如过长的wildcard模糊查询、带大量should子句的布尔查询、对高基数字段做大范围terms聚合。这些语句在索引数据量小的时候表现良好数据量一上来就原形毕露。该改写查询的改写查询该加 filter 缓存的加 filter 缓存不能指望 Elasticsearch 自动做查询优化。第二件事是索引段合并。Elasticsearch 的数据存储由多个 segment段组成后台会定期合并段以提高查询效率。但如果写入压力大段数量可能堆积查询时需要扫描的段过多性能就下来了。可以主动触发合并或者从写入端做优化比如把批量写入的 size 调大减少碎片化的段产生。POST /articles/_forcemerge?max_num_segments1这个命令会把索引的所有段强制合并成 1 个对只读索引或者低写入频率的索引效果很好。但对高写入索引不能频繁执行否则会导致写入放大。再就是容量规划。我遇到过一次线上集群磁盘告警排查后发现是一个索引的副本数设成了 3每个节点上都有一份完整数据磁盘用量直接翻了好几倍。后来把副本数调整为 1并给索引设置了生命周期策略比如超过 90 天的日志索引自动删除或迁移到冷存储集群压力立刻降下来了。索引维护还包含一个很日常但重要的工作定期做索引重建。业务的数据模型会迭代比如文章新增了字段“作者等级”或者“分类”字段从 keyword 改成了带层级关系的类型这些变更往往不能原地修改映射只能重建索引。我的建议是每次变更尽量用“索引别名 新旧索引无缝切换”的方式先在测试环境跑通新索引再把业务流量切过去尽量避免因为重建索引导致服务不可用。这里还有一个非常值得说的经验别让检索系统承担超过它职责范围的工作。有人会把复杂的权限过滤、多表关联数据拉取、甚至业务逻辑都塞进检索查询里结果查询语句又长又复杂性能极差。正确的姿势是检索系统只负责尽可能快地召回“候选集”再通过 filter、post_filter 或者其他外部服务完成精确过滤把复杂业务逻辑拆解出去。我见过有团队把用户标签过滤做成了几万字的巨型查询后来改成“检索召回 外部服务二次过滤”的架构性能提升特别明显。4.3 边界场景与体验细节检索网站做得好不好边界场景最考验功力。空查询、超长查询、特殊字符、拼音、中英混合这些场景如果不处理用户很容易产生“这个网站搜索真难用”的直观感受。空查询的体验很容易被忽略。用户进入页面后不输入任何内容就点搜索有些系统直接返回空结果有些系统则不处理导致前端报错。合理的设计是空查询时默认展示热门内容或者最新内容既给用户一个明确的着陆页也减轻后端压力。实现上就是在后端判断查询词是否为空为空则走match_all加排序的兜底查询。超长查询词会带来两个问题一是分词过多导致查询慢二是结果噪声大。我通常会给查询词做一个长度限制比如最多 50 个字符超长直接截断或者提示用户精简关键词。这个限制可以在后端网关层做也可以在 Elasticsearch 前做一层参数校验。对于连续特殊字符比如“”或者“。。。。”直接过滤掉比较合适它们对语义匹配毫无帮助只会污染分词结果。拼音搜索是中文检索网站体验差异化的一个点。前面建的索引里给 title 加了 pinyin 子字段查询的时候可以用 multi_match 同时匹配原文和拼音字段。实现后用户输入“sj”也能找到“手机”对移动端用户特别友好。不过拼音搜索也有代价——拼音有多音字可能有噪声匹配所以最好只把它作为“附加召回”手段权重放低一点不要影响原始文本匹配的主导地位。还有一个我特别想说的体验细节搜索结果的摘要和关键词高亮。检索网站返回结果的时候不能只给一个标题链接正文摘要最好能展示关键词附近的上下文。Elasticsearch 的 highlight 功能可以做到但默认高亮的片段截取逻辑有时不理想会截出“…苹果…手机…”这种没头没尾的中断。实际项目中可以自定义highlight的fragment_size和number_of_fragments或者用fast_vector_highlighter结合 term vector 做更精准的高亮。简单举个例子把高亮片段控制在 120 个字以内、每个字段最多 2 个片段展示效果会舒服很多highlight: { fields: { content: { fragment_size: 120, number_of_fragments: 2 } } }5. 工具选择与团队协作经验5.1 生态组件与运维配套做检索网站只搞定 Elasticsearch 本身还不够。一个真正能上生产、稳定运行的检索系统周边配套的组件基本是必需品。数据同步方面Logstash 是很多团队的入门选择它通过 JDBC 插件定时从数据库拉取数据配置简单文档也多。但 Logstash 的资源占用和同步延迟并不理想对实时性要求高的业务我后来换成了 Canal 监听 binlog配合 Kafka 解耦再写一个轻量级消费者把变更写入 Elasticsearch。这套架构虽然初建成本高一些但后续的实时性、扩展性和可维护性都明显更好。可视化运维方面Kibana 的 Dev Tools 用来调试查询非常顺手监控大盘用 Grafana 接 Elasticsearch 指标或者用 ES 自带的监控功能都可以。如果公司已经上了 Prometheus 体系用 ES 官方 exporter 把指标接进去是最省事的方案。我自己的体会是检索网站上线后告警必须尽早配。最起码要监控集群状态是否 green/yellow/red、查询 p99 延迟、写入延迟、磁盘使用率、索引段数量这几个指标。不然很容易出现“用户已经反馈搜索很慢管理员还浑然不觉”的处境。5.2 搜索效果验证与回归方法搜索相关性的调优有一个天然难点没有绝对正确的标准答案。同一个查询“手机”有人想找iPhone有人想找安卓搜索引擎只能尽量平衡大多数人的预期。所以我特别建议在项目里建立一个“查询效果评估集”把高频搜索词和期望结果整理出来每次调优后跑一遍回归。实际操作中我会从真实的搜索日志里抽取 Top 100 的高频词每个词人工标注 5 到 10 条理想结果然后就形成了一份回归测试集。后续修改分词器、权重、过滤规则时先跑一遍测试集可以用 NDCG、MRR 这类指标量化评估相关性是否提升。这个工作听起来费时间但对长期迭代意义很大很多团队就是缺少这一步导致搜索效果一直靠“感觉”越调越乱。回归之外A/B 测试也是验证搜索体验的好办法。对部分线上流量使用新的相关度模型或排序策略对比用户的点击率、跳出率、成交转化率。这一步不是所有场景都必要但如果你做的检索网站是电商、内容平台这类对搜索转化有直接要求的业务A/B 测试基本是必经之路。6. 项目复盘与个人建议6.1 最容易踩的坑汇总做检索网站这一路走来我整理过一份自己的“翻车记录”每次新项目启动都会翻一遍防止再踩。这里挑几个印象最深的坑聊聊。坑一一开始就追求完美架构。很多检索项目刚起步的时候数据量只有几万条却上了 Canal、Kafka、Spark 全套实时计算架构消耗的人力远比业务价值大。我的建议是先按当前数据量和未来半年的预估量做方案预留扩展点就好不要过度设计。坑二忽略中文分词的自定义需求。默认的 IK 词典虽然包含了不少词条但垂直行业的专有名词、品牌名、人名多半没有收录比如生僻药名、产品型号搜索这类词的时候经常召回不准。一定要尽早建立自定义词典和停用词的维护流程哪怕一开始只有十几个词。坑三只建索引不做清理。偶尔要用一张临时表的历史数据直接把几千条数据塞进索引用完就忘了。结果这个索引一直躺在集群里占着分片资源磁盘告警的时候查了半天才发现。上线之初就应该配置好索引的生命周期策略。坑四对查询语句缺少审计。前端自由拼查询条件导致一个页面几种不同的查询写法有的用match有的用query_string有的用wildcard对缓存和性能都极不友好。后来我强制把所有查询走同一套服务端组装逻辑查询词只做参数传入情况才好转。6.2 后续可以扩展的方向检索网站的演进方向通常是从“能搜”到“好搜”再到“搜得懂”。基础功能稳定后可以在几个方向上继续投入。第一是搜索建议。用户在搜索框输入过程中下拉列表实时展示补全词和热门词。Elasticsearch 的suggesters或者前缀匹配都能实现基础的补全数据量小的时候体验就很好。第二是语义检索。传统检索基于关键词匹配很难处理“苹果手机和安卓手机哪个好”这类带语义的问题。把文本转成向量通过向量相似度召回相关文档也就是目前在用的混合检索方案关键词 向量能明显提升检索效果。这需要引入向量模型和向量索引Elasticsearch 8.x 以后的原生向量检索能力已经能支撑中小规模的场景。第三是搜索排序的个性化。结合用户的历史行为对某些标签、品类做加权实现“千人千面”的搜索结果。这一步通常需要引入用户行为数据并做实时特征计算复杂度会上升一个量级但带来的用户体验提升也最直观。我在实际项目中最大的感触是检索系统不是一个“做完就能放着不管”的模块它和数据、业务、用户行为都紧密相关需要持续打磨。如果团队里有人能专门负责搜索体验的迭代定期看搜索日志、调分词器、优化排序策略这个检索网站的服务质量会越来越扛打。毕竟一个真正好用的搜索功能用户是能实实在在感知到的。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →