前段时间线上商城的搜索模块被用户疯狂吐槽:“搜个‘苹果手机壳’,结果第一页全是‘苹果水果’和‘华为手机壳’,怎么想的?”由于之前的搜索业务是直接用 MySQL 的 LIKE 模糊查询糊出来的,数据量上去之后不仅慢得像蜗牛,召回率 and 精确度也惨不忍睹。为了彻底解决这个问题,我们痛下决心把搜索独立出来,上了一套“Debezium CDC + Flink + Elasticsearch”的经典架构。本以为配好数据同步就万事大吉,没想到在实际的搜索相关性(Relevancy)调优上,又踩了一堆意想不到的大坑。

第一个大坑就是同步延迟。我们最初用的是传统定时扫表的方案,每 5 分钟扫一次 MySQL 的商品表写入 ES。结果运营同学直接崩溃了:后台改了商品库存和价格,前台搜索页 5 分钟都不能同步,导致大量用户下单时报“商品已下架”或价格变动。干脆一步到位,直接上 Flink-CDC (基于 Debezium) 监听 MySQL binlog。

在同步链路调通之后,真正的重头戏来到了 Elasticsearch 的 BM25 相关性算法调优。ES 默认的 BM25 相似度计算公式中,有两个核心参数:k1(控制词频饱和度)和 b(控制文档长度惩罚)。 默认情况下,k1 = 1.2b = 0.75。对于一般的网页搜索这个默认值没毛病,但在商品搜索场景下,商品标题通常非常短且堆砌词严重(比如“2026新款 苹果/Apple iPhone 15 Pro Max 手机壳 硅胶防摔保护套”),默认的 b = 0.75 会过度惩罚较长的标题,导致一些长标题的精准匹配商品排名靠后。

经过几轮灰度测试和埋点数据排查,我们发现把商品标题字段的 b 值调小到 0.3,将 k1 调大到 2.0(增强主词频的影响力),能极大提升搜索词的精准度。以下是我们最终在线上落地的 Elasticsearch 索引 Mapping 与 Similarity 配置,大家可以参考:

{
  "settings": {
    "index": {
      "number_of_shards": 3,
      "number_of_replicas": 1
    },
    "analysis": {
      "analyzer": {
        "ik_max_word_custom": {
          "type": "custom",
          "tokenizer": "ik_max_word",
          "char_filter": [
            "html_strip"
          ],
          "filter": [
            "lowercase"
          ]
        }
      }
    },
    "similarity": {
      "title_bm25_custom": {
        "type": "BM25",
        "k1": 2.0,
        "b": 0.3
      },
      "desc_bm25_custom": {
        "type": "BM25",
        "k1": 1.2,
        "b": 0.8
      }
    }
  },
  "mappings": {
    "properties": {
      "product_id": {
        "type": "long"
      },
      "title": {
        "type": "text",
        "analyzer": "ik_max_word_custom",
        "search_analyzer": "ik_smart",
        "similarity": "title_bm25_custom",
        "fields": {
          "keyword": {
            "type": "keyword",
            "ignore_above": 256
          }
        }
      },
      "description": {
        "type": "text",
        "analyzer": "ik_max_word_custom",
        "search_analyzer": "ik_smart",
        "similarity": "desc_bm25_custom"
      },
      "price": {
        "type": "double"
      },
      "status": {
        "type": "integer"
      },
      "created_at": {
        "type": "date",
        "format": "yyyy-MM-dd HH:mm:ss||strict_date_optional_time||epoch_millis"
      }
    }
  }
}

针对商品标题,由于很多商家会故意堆砌关键词,如果完全不做长度惩罚,长标题很容易因为包含多个无意义词而排在前面。通过把 b 降到 0.3,我们降低了标题长度对得分的影响,使那些精准命中的短标题能够排在更合理的位置。

同时,我们遇到了多字段检索的另一个坑:使用 multi_match 查询时,如果简单使用 best_fields 模式,搜索结果完全被主名字的得分主导,副词(如描述、标签)的贡献直接被抹杀。后来折腾了很久,干脆换成了 cross_fields 模式,并且对 title 进行了 ^3 的权重提升,对 description 做了 ^0.5 的降权,才彻底搞定了商品名和描述之间的相关性心智负担。

这套优化上线后,搜索跳出率直接下降了 12%,GMV 也有了小幅拉升。不过,搜索引擎的调优永远是个黑盒,AB 测试和长尾搜索词的持续分析才是硬道理,最近我们又在盘算着接入点击反馈数据做二次精排,不知道社区里有没有大佬做过这套闭环的?