土法炼钢兴趣小组的算法知识备份

【系统架构设计百科】搜索引擎架构:倒排索引之上的系统设计

文章导航

分类入口
architecture
标签入口
#search-engine#inverted-index#Elasticsearch#relevance#distributed-index

目录

某电商平台的商品搜索服务,商品数量 5000 万,日均搜索请求 2 亿次。团队最初用 MySQL 的 LIKE '%关键词%' 做全文搜索,单次查询延迟 800ms,CPU 利用率常年 90% 以上。后来换成 Elasticsearch,单次查询延迟降到 15ms,问题似乎解决了。但三个月后,集群开始出状况:分片数从最初的 50 膨胀到 8000,集群状态更新(Cluster State Update)耗时从毫秒级涨到 30 秒,频繁触发 Full GC,搜索请求超时率飙到 5%。运维团队发现,他们理解倒排索引的原理,但完全没有理解倒排索引之上的那一整套系统设计。

这是搜索系统工程化的典型困境:倒排索引是搜索引擎的核心数据结构,但一个生产级搜索系统远不止一个倒排索引。从用户输入查询词到最终返回排序结果,中间经历了查询解析、分词分析、分布式召回、相关性排序、结果聚合、缓存优化等多个环节,每个环节都有自己的工程挑战。

这篇文章回答一个核心问题:一个生产级搜索系统除了倒排索引,还需要哪些架构组件?

上一篇 中我们讨论了流处理架构如何处理实时数据流,本文聚焦另一个数据密集型场景——当你需要从海量数据中快速找到匹配结果时,搜索系统的架构设计决定了响应速度、结果质量和运维成本的上限。

适用范围说明 本文讨论的 Elasticsearch 行为基于 7.x / 8.x 版本。Lucene 相关内容基于 9.x。BM25 算法的讨论基于 Robertson 和 Zaragoza 在 2009 年的论文”The Probabilistic Relevance Framework: BM25 and Beyond”。向量检索部分基于 Elasticsearch 8.0+ 的 dense_vector 字段类型和 HNSW 算法实现。


一、倒排索引:搜索引擎的基石

正排索引与倒排索引的区别

理解倒排索引(Inverted Index),先从正排索引(Forward Index)说起。

正排索引的结构是”文档 -> 词项”:给定一个文档 ID,可以快速找到这个文档包含哪些词。关系型数据库的 B+ 树索引就是一种正排索引——按主键定位行,然后读取行内的所有字段。

正排索引:
文档1 -> ["搜索", "引擎", "架构", "设计"]
文档2 -> ["倒排", "索引", "数据", "结构"]
文档3 -> ["搜索", "算法", "排序", "引擎"]

这种结构对精确查找很高效,但对全文搜索是灾难性的。当用户搜索”搜索引擎”时,你需要遍历所有文档,逐个检查每个文档是否包含这两个词。文档数量越大,查询越慢——这就是 LIKE '%keyword%' 慢的根本原因,它本质上是全表扫描。

倒排索引把映射关系反过来——“词项 -> 文档”:给定一个词项(Term),可以快速找到哪些文档包含这个词。

倒排索引:
"搜索" -> [文档1, 文档3]
"引擎" -> [文档1, 文档3]
"架构" -> [文档1]
"设计" -> [文档1]
"倒排" -> [文档2]
"索引" -> [文档2]
"数据" -> [文档2]
"结构" -> [文档2]
"算法" -> [文档3]
"排序" -> [文档3]

当用户搜索”搜索引擎”时,只需要在倒排索引中查找”搜索”和”引擎”两个词项,取交集,就得到 [文档1, 文档3]。时间复杂度从 O(N) 降到 O(1) 的词典查找加上 O(min(m,n)) 的倒排列表合并,其中 m 和 n 是两个词项的倒排列表长度。

倒排索引的内部结构

一个完整的倒排索引由三个核心组件构成:

词典(Term Dictionary)。 存储所有不重复的词项,支持快速查找。Lucene 使用 FST(Finite State Transducer,有限状态转换器)作为词典的数据结构。FST 是一种有向无环图,同时具备 Trie 树的前缀压缩能力和有限自动机的空间效率,在 Lucene 的实现中通常可以完整载入内存。

倒排列表(Posting List)。 每个词项对应一个倒排列表,记录包含该词项的所有文档 ID。为了支持短语查询和邻近查询,倒排列表还可以存储词项在文档中的位置信息(Position)和偏移信息(Offset)。

词项频率与文档频率。 倒排列表中通常还记录词项频率(Term Frequency,TF)——该词项在某个文档中出现的次数,以及文档频率(Document Frequency,DF)——包含该词项的文档总数。这两个值是相关性排序的基础数据。

用一个具体的例子说明完整的倒排索引结构:

词典                    倒排列表
────────────────────────────────────────────────────────
"elasticsearch"  ->  [(doc1, tf=3, pos=[5,12,28]),
                      (doc5, tf=1, pos=[7]),
                      (doc9, tf=2, pos=[1,15])]
                     df=3

"搜索"           ->  [(doc1, tf=2, pos=[1,20]),
                      (doc2, tf=5, pos=[3,8,11,25,30]),
                      (doc3, tf=1, pos=[4]),
                      (doc7, tf=3, pos=[2,9,16])]
                     df=4

Lucene 的段(Segment)架构

Lucene 不是把所有文档放在一个巨大的倒排索引中,而是采用段(Segment)架构。每个段是一个独立的、不可变的(Immutable)倒排索引。

写入流程如下:

  1. 新文档先写入内存缓冲区(In-Memory Buffer)。
  2. 当缓冲区满或者经过一定时间间隔,内存缓冲区的内容被刷写(Flush)成一个新的段,写入磁盘。
  3. 新的段一旦写入磁盘就不再修改。删除操作通过一个单独的 .del 文件标记被删除的文档 ID,而不是从段中物理删除。
  4. 后台的合并进程(Merge Process)定期把多个小段合并成一个大段,并在合并时物理删除标记为已删除的文档。

这种设计的好处是:段的不可变性消除了写入时的锁竞争,多个读线程可以安全地并发读取同一个段。坏处是:段越多,搜索时需要查询的段越多,每个段都要做一次词典查找和倒排列表遍历,然后合并结果。这就是 Lucene 需要段合并的根本原因——把过多的小段合并成少量大段,减少搜索时的开销。

// Lucene 段合并策略配置示例
IndexWriterConfig config = new IndexWriterConfig(analyzer);

// TieredMergePolicy 是 Lucene 默认的合并策略
TieredMergePolicy mergePolicy = new TieredMergePolicy();
// 单个段的最大大小,超过此值的段不参与合并
mergePolicy.setMaxMergedSegmentMB(5 * 1024); // 5 GB
// 一次合并操作最多合并多少个段
mergePolicy.setMaxMergeAtOnce(10);
// 每层允许的最大段数量,超过后触发合并
mergePolicy.setSegmentsPerTier(10);

config.setMergePolicy(mergePolicy);

// 控制刷写频率:每累积多少文档刷写一次
config.setMaxBufferedDocs(10000);
// 或者按内存大小控制
config.setRAMBufferSizeMB(256);

倒排列表的压缩

倒排列表中存储的文档 ID 是有序的,相邻 ID 之间的差值(Delta)通常比 ID 本身小得多。Lucene 利用这一特性,不直接存储文档 ID,而是存储 Delta 编码后的值,然后用 Variable-Byte Encoding 或 PForDelta(Patched Frame of Reference Delta)算法进一步压缩。

原始文档 ID 列表:  [1, 5, 8, 12, 15, 100, 103]
Delta 编码后:      [1, 4, 3, 4,  3,  85,  3]

Delta 编码后的值普遍更小,需要更少的比特位来表示,压缩率大幅提升。Lucene 9.x 使用的 PForDelta 编码通常能将倒排列表压缩到原始大小的 10%-20%。


二、分词与分析器管线

分析器的三阶段管线

倒排索引存储的是词项,但用户输入的是原始文本。从原始文本到词项的转换过程称为文本分析(Text Analysis),由分析器(Analyzer)完成。

一个 Elasticsearch 分析器由三个组件按顺序组成:

  1. 字符过滤器(Character Filter)。 对原始文本做预处理,在分词之前执行。典型用途包括去除 HTML 标签、将繁体字转换为简体字、替换特殊字符。
  2. 分词器(Tokenizer)。 把预处理后的文本切分成词项(Token)。这是分析器的核心组件。不同的语言需要不同的分词器——英文可以按空格和标点切分,中文需要基于词典或统计模型的分词算法。
  3. 词项过滤器(Token Filter)。 对分词结果做后处理。典型操作包括转换为小写、去除停用词(Stop Words)、词干提取(Stemming)、同义词扩展。
原始文本: "<p>Elasticsearch 搜索引擎的架构设计</p>"

字符过滤器 (html_strip):
  -> "Elasticsearch 搜索引擎的架构设计"

分词器 (ik_max_word):
  -> ["Elasticsearch", "搜索引擎", "搜索", "引擎", "的", "架构", "设计"]

词项过滤器 (lowercase + stop):
  -> ["elasticsearch", "搜索引擎", "搜索", "引擎", "架构", "设计"]

中文分词的工程挑战

英文分词相对简单——单词之间有天然的空格分隔符。中文没有这个优势。“乒乓球拍卖完了”既可以分成”乒乓球/拍卖/完/了”,也可以分成”乒乓/球拍/卖/完/了”,语义完全不同。

Elasticsearch 生态中最常用的中文分词器是 IK Analyzer,它提供两种分词模式:

两种模式的取舍:ik_max_word 覆盖的词项多,召回率高,但索引体积更大,且可能引入噪声;ik_smart 索引体积小,精确度高,但可能漏掉用户用不同粒度搜索时的匹配。

一种常见的工程实践是:索引时用 ik_max_word,搜索时用 ik_smart。索引时尽可能多地建立词项,确保各种粒度的搜索都能命中;搜索时用粗粒度切分,减少不必要的召回。

{
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart"
      }
    }
  }
}

自定义分析器

生产环境中很少直接用内置分析器,通常需要自定义。以电商商品搜索为例,一个典型的自定义分析器配置:

{
  "settings": {
    "analysis": {
      "char_filter": {
        "brand_synonym_filter": {
          "type": "mapping",
          "mappings": [
            "华为 => HUAWEI",
            "苹果手机 => iPhone"
          ]
        }
      },
      "tokenizer": {
        "ik_smart_tokenizer": {
          "type": "ik_smart"
        }
      },
      "filter": {
        "my_stopwords": {
          "type": "stop",
          "stopwords": ["的", "了", "在", "是", "我", "有"]
        },
        "product_synonym": {
          "type": "synonym_graph",
          "synonyms_path": "analysis/product_synonyms.txt"
        }
      },
      "analyzer": {
        "product_analyzer": {
          "type": "custom",
          "char_filter": ["brand_synonym_filter"],
          "tokenizer": "ik_smart_tokenizer",
          "filter": ["my_stopwords", "product_synonym", "lowercase"]
        }
      }
    }
  }
}

同义词文件 product_synonyms.txt 的内容可能是:

手机,移动电话,智能手机
笔记本,笔记本电脑,laptop
耳机,耳麦,headphone

这样用户搜索”手机”时,也能匹配到包含”智能手机”或”移动电话”的商品。


三、分布式索引的分片策略

为什么需要分片

当索引数据量超过单个节点的存储或计算能力时,就需要把索引拆分到多个节点上。Elasticsearch 中的分片(Shard)就是 Lucene 索引的分布式化——每个分片是一个独立的 Lucene 索引,可以分布在集群的不同节点上。

分片解决两个问题:

  1. 水平扩展存储:单个节点的磁盘有限,分片可以把数据分散到多个节点。
  2. 并行化查询:搜索请求可以同时在多个分片上并行执行,然后合并结果。

但分片不是越多越好。每个分片都有开销:内存中的 FST 词典、段元数据、线程池资源、集群状态中的分片元信息。Elasticsearch 官方建议单个分片大小在 10-50 GB 之间,每个节点的分片数不超过 20 个/GB 堆内存。

三种分片策略

按文档 ID 哈希分片

最常见的策略。Elasticsearch 默认使用文档的 _id 字段做哈希,公式为:

shard_num = hash(_routing) % number_of_primary_shards

其中 _routing 默认等于 _id。这种策略保证文档均匀分布在各个分片上,但也意味着查询时必须查询所有分片(scatter-gather),因为你不知道目标文档在哪个分片上。

按路由键分片

当查询模式有明确的维度时,可以用自定义路由键(Routing Key)。例如电商平台的订单搜索,绝大多数查询都带有 user_id,那么可以用 user_id 作为路由键:

PUT /orders/_doc/order_12345?routing=user_6789
{
  "order_id": "order_12345",
  "user_id": "user_6789",
  "product": "MacBook Pro",
  "amount": 14999,
  "created_at": "2025-03-15T10:30:00Z"
}

好处是:查询某个用户的订单时,只需要查询一个分片,而不是所有分片。坏处是:如果某些用户的订单量远大于其他用户(大卖家),会导致分片数据不均匀(数据倾斜)。

按时间分片

日志搜索和监控数据的经典策略。Elasticsearch 配合 ILM(Index Lifecycle Management)实现按时间滚动创建索引:

PUT _ilm/policy/logs_policy
{
  "policy": {
    "phases": {
      "hot": {
        "min_age": "0ms",
        "actions": {
          "rollover": {
            "max_age": "1d",
            "max_primary_shard_size": "50gb"
          }
        }
      },
      "warm": {
        "min_age": "7d",
        "actions": {
          "shrink": {
            "number_of_shards": 1
          },
          "forcemerge": {
            "max_num_segments": 1
          }
        }
      },
      "cold": {
        "min_age": "30d",
        "actions": {
          "searchable_snapshot": {
            "snapshot_repository": "my_s3_repo"
          }
        }
      },
      "delete": {
        "min_age": "90d",
        "actions": {
          "delete": {}
        }
      }
    }
  }
}

这种策略的优势在于:查询通常带有时间范围,可以精确定位到相关索引,跳过大量无关数据;旧数据可以自动降级到更廉价的存储层。

三种分片策略的对比

维度 按文档 ID 哈希 按路由键 按时间分片
数据分布 均匀 可能倾斜 按时间均匀
查询效率 scatter-gather,查所有分片 可定向查询单个分片 可按时间范围裁剪
典型场景 通用全文搜索 多租户、用户维度查询 日志、监控、时序数据
扩展方式 需要 reindex 才能改变分片数 同左 新索引自动按策略创建
数据倾斜风险 高(热点路由键)
冷热分离 需要额外配置 需要额外配置 天然支持

生产环境中这三种策略经常组合使用。例如日志系统用时间分片做索引滚动,同时在单个索引内部用文档 ID 哈希分片。


四、相关性排序的工程实现

从 TF-IDF 到 BM25

搜索不只是找到匹配的文档,还要把最相关的文档排在前面。相关性排序(Relevance Ranking)是搜索引擎的核心差异化因素。

TF-IDF(Term Frequency-Inverse Document Frequency) 是最经典的文本相关性模型。它的核心直觉是:一个词在某个文档中出现得越频繁(TF 高),同时在整个语料库中出现得越少见(IDF 高),这个词对于这个文档的区分度就越高。

TF(t, d) = 词项 t 在文档 d 中出现的次数
IDF(t) = log(N / df(t))
TF-IDF(t, d) = TF(t, d) * IDF(t)

其中:
N = 语料库中的文档总数
df(t) = 包含词项 t 的文档数

TF-IDF 的问题在于 TF 部分没有饱和机制——如果一个词在文档中出现了 100 次,TF 值是出现 10 次的 10 倍。但直觉上,一个词出现 100 次和出现 10 次,相关性的差距远没有 10 倍那么大。

BM25(Best Matching 25) 是 TF-IDF 的改进版,由 Stephen Robertson 等人在概率信息检索框架下推导得出。Elasticsearch 从 5.0 版本开始默认使用 BM25。BM25 的公式:

score(q, d) = SUM over t in q [
    IDF(t) * (TF(t,d) * (k1 + 1)) / (TF(t,d) + k1 * (1 - b + b * |d| / avgdl))
]

其中:
IDF(t) = log(1 + (N - df(t) + 0.5) / (df(t) + 0.5))
k1 = 词频饱和参数,默认 1.2
b = 文档长度归一化参数,默认 0.75
|d| = 文档 d 的长度(词项总数)
avgdl = 所有文档的平均长度

BM25 相比 TF-IDF 的两个关键改进:

  1. TF 饱和:分子中 TF * (k1 + 1) 与分母中 TF + k1 * ... 的比值会随着 TF 增大趋近于一个上限值 (k1 + 1),不会无限增长。参数 k1 控制饱和的速度:k1 越大,饱和越慢,TF 的影响越大。
  2. 文档长度归一化:参数 b 控制文档长度的影响。b=0 时不考虑文档长度;b=1 时完全按文档长度做归一化。默认 b=0.75 意味着长文档会受到一定程度的惩罚——因为长文档天然有更多词项,TF 值偏高,如果不归一化,长文档会有不合理的优势。

BM25 参数调优

在 Elasticsearch 中可以对每个字段自定义 BM25 参数:

{
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "similarity": {
          "type": "BM25",
          "k1": 1.5,
          "b": 0.3
        }
      },
      "description": {
        "type": "text",
        "similarity": {
          "type": "BM25",
          "k1": 1.2,
          "b": 0.75
        }
      }
    }
  }
}

title 字段的 b 设为 0.3,因为商品标题长度差异不大(通常 5-30 个词),不需要强力的长度归一化。description 字段用默认值,因为商品描述的长度差异大,需要归一化来避免长描述不合理地获得高分。

向量检索:语义搜索的工程基础

BM25 依赖词项的精确匹配——用户搜索”手机”不会匹配到只包含”智能终端”的文档,即使两者语义相近。向量检索(Vector Search)通过把文本编码为高维向量,在向量空间中计算语义相似度,弥补了词汇鸿沟(Vocabulary Mismatch)问题。

Elasticsearch 8.0 引入了原生的向量检索支持:

{
  "mappings": {
    "properties": {
      "title": {
        "type": "text"
      },
      "title_vector": {
        "type": "dense_vector",
        "dims": 768,
        "index": true,
        "similarity": "cosine"
      }
    }
  }
}

向量检索使用 kNN(k-Nearest Neighbors)算法,但精确 kNN 在百万级向量上的计算成本太高。Elasticsearch 使用 HNSW(Hierarchical Navigable Small World)算法做近似最近邻(ANN)搜索,在精度和速度之间取得平衡。

{
  "knn": {
    "field": "title_vector",
    "query_vector": [0.12, -0.34, 0.56, ...],
    "k": 10,
    "num_candidates": 100
  }
}

num_candidates 控制搜索精度和性能的平衡:值越大,搜索越精确但越慢。Elasticsearch 文档建议 num_candidates 设为 k 的 5-10 倍。

混合检索:BM25 + 向量的融合

实际生产中,纯关键词搜索和纯向量搜索各有局限。最佳实践是混合检索(Hybrid Search)——同时使用 BM25 和向量检索,然后用 RRF(Reciprocal Rank Fusion)等算法融合排序结果。

{
  "query": {
    "bool": {
      "should": [
        {
          "match": {
            "title": {
              "query": "无线降噪耳机",
              "boost": 1.0
            }
          }
        }
      ]
    }
  },
  "knn": {
    "field": "title_vector",
    "query_vector": [0.12, -0.34, 0.56, ...],
    "k": 10,
    "num_candidates": 100,
    "boost": 0.5
  },
  "rank": {
    "rrf": {
      "window_size": 100,
      "rank_constant": 60
    }
  }
}

RRF 的计算公式简洁:

RRF_score(d) = SUM over r in rankings [1 / (k + rank_r(d))]

其中 k 是 rank_constant,rank_r(d) 是文档 d 在第 r 个排序列表中的排名。

RRF 的优势在于它不需要对不同排序列表的分数做归一化——因为它只使用排名,不使用原始分数。这避免了 BM25 分数和余弦相似度分数在数值尺度上不可比的问题。


五、Elasticsearch 集群架构

节点角色

Elasticsearch 集群中的节点可以承担不同的角色。在 7.9 之前,一个节点默认同时是 master-eligible、data 和 ingest 节点。7.9 之后引入了更细粒度的角色划分:

角色 职责 资源特征
master 管理集群状态、分片分配、索引创建/删除 低 CPU、低磁盘,需稳定网络
data_content 存储和搜索非时序数据 高磁盘、高内存
data_hot 存储最新的时序数据(高写入吞吐) 高 IOPS SSD、高内存
data_warm 存储较旧的时序数据(低写入、中等查询) 大容量 SSD 或 HDD
data_cold 存储冷数据(极低查询频率) 大容量 HDD 或对象存储
ingest 执行 ingest pipeline(数据预处理) 高 CPU
coordinating 只负责请求路由和结果聚合 高内存、高网络带宽
ml 执行机器学习任务 高 CPU、高内存
transform 执行 transform 任务 中等 CPU 和内存

生产集群的基本原则是角色分离:master 节点不做数据存储,data 节点不竞选 master。这样 master 节点不会因为数据节点的高负载而受影响,集群稳定性更高。

典型的中等规模生产集群配置:

# master 节点配置(3 个专用 master 节点)
node.roles: [master]
cluster.initial_master_nodes: ["master-1", "master-2", "master-3"]

# 避免 master 节点处理搜索和索引请求
# 推荐配置:8 CPU、16 GB 内存、100 GB SSD
# 热数据节点配置
node.roles: [data_hot, data_content, ingest]

# 推荐配置:32 CPU、64 GB 内存、2 TB NVMe SSD
# JVM 堆内存设置为物理内存的一半,但不超过 30.5 GB
# (超过 32 GB 会失去 JVM 压缩指针优化)
# 协调节点配置
node.roles: []

# 不承担任何角色的节点自动成为协调节点
# 推荐配置:16 CPU、32 GB 内存

分片分配机制

当创建索引或集群拓扑变化时,master 节点负责决定每个分片应该分配到哪个数据节点。这个过程由分片分配器(Shard Allocator)完成,它需要平衡多个约束:

  1. 分片均衡:各节点上的分片数量应大致相等。
  2. 副本隔离:主分片和副本分片不能在同一个节点上(否则节点故障时数据丢失)。
  3. 机架感知:在多机架部署中,主分片和副本分片应分布在不同机架上。
  4. 磁盘水位线:当节点磁盘使用率超过高水位线(默认 90%),停止向该节点分配新分片。
# elasticsearch.yml 中的分片分配相关配置

# 磁盘水位线
cluster.routing.allocation.disk.watermark.low: "85%"
cluster.routing.allocation.disk.watermark.high: "90%"
cluster.routing.allocation.disk.watermark.flood_stage: "95%"

# 机架感知
cluster.routing.allocation.awareness.attributes: rack_id
node.attr.rack_id: rack_1

# 限制单次恢复的并发分片数
cluster.routing.allocation.node_concurrent_recoveries: 2

# 限制节点间恢复的网络带宽
indices.recovery.max_bytes_per_sec: "200mb"

近实时搜索的实现

Elasticsearch 的”近实时”(Near Real-Time, NRT)搜索是其核心卖点之一。一个文档从写入到可被搜索,默认延迟是 1 秒。这个延迟的来源是 refresh 操作。

写入流程的完整路径:

  1. 文档到达数据节点,写入 Lucene 的内存缓冲区,同时写入事务日志(Translog)。
  2. 每隔 index.refresh_interval(默认 1 秒),执行一次 refresh:把内存缓冲区的数据刷写成一个新的 Lucene 段,并打开一个新的 IndexSearcher。此时数据变得可搜索,但尚未持久化到磁盘。
  3. 每隔一段时间(或 translog 大小达到阈值),执行一次 flush:调用 Lucene 的 commit(),把所有未持久化的段写入磁盘,并清空 translog。
写入 -> 内存缓冲区 + translog
         |
         | (refresh, 默认每 1 秒)
         v
      新 Lucene 段 (内存映射文件, 可搜索)
         |
         | (flush, translog 达到阈值时)
         v
      磁盘持久化 (fsync)

如果对实时性要求不高(比如日志场景),可以增大 refresh_interval 来提升写入吞吐:

PUT /logs-2025-03-15
{
  "settings": {
    "index.refresh_interval": "30s",
    "index.translog.durability": "async",
    "index.translog.sync_interval": "5s"
  }
}

这里有一个需要理解的权衡:translog.durability 设为 async 意味着 translog 不再在每次写入后 fsync,而是每隔 sync_interval 批量 fsync 一次。这提升了写入吞吐,但在节点崩溃时可能丢失最近 sync_interval 时间窗口内的数据。对于日志这类可容忍少量丢失的场景,这个取舍是合理的。

搜索请求的执行流程

一个搜索请求在 Elasticsearch 集群中的执行分为两个阶段:

查询阶段(Query Phase):

  1. 客户端发送搜索请求到一个协调节点(Coordinating Node)。
  2. 协调节点把请求转发到目标索引的所有分片(每个分片选择一个副本——主分片或副本分片,默认轮询选择以实现负载均衡)。
  3. 每个分片在本地执行查询,返回匹配文档的 ID 和排序值(不返回文档内容)。
  4. 协调节点收集所有分片的结果,做全局排序,确定最终的 Top-K 文档列表。

取回阶段(Fetch Phase):

  1. 协调节点根据 Top-K 列表,向相关分片发送 multi-get 请求,获取完整的文档内容。
  2. 协调节点把完整文档组装成最终响应,返回给客户端。
sequenceDiagram
    participant C as 客户端
    participant CN as 协调节点
    participant S1 as 分片1
    participant S2 as 分片2
    participant S3 as 分片3

    C->>CN: 搜索请求
    Note over CN: 查询阶段(Query Phase)

    par 并行查询所有分片
        CN->>S1: 查询(返回 doc_id + score)
        CN->>S2: 查询(返回 doc_id + score)
        CN->>S3: 查询(返回 doc_id + score)
    end

    S1-->>CN: [(doc1, 9.5), (doc4, 7.2)]
    S2-->>CN: [(doc8, 8.8), (doc3, 6.1)]
    S3-->>CN: [(doc12, 9.1), (doc7, 5.5)]

    Note over CN: 全局排序 -> Top-K: [doc1, doc12, doc8]
    Note over CN: 取回阶段(Fetch Phase)

    par 并行获取文档内容
        CN->>S1: 获取 doc1 完整内容
        CN->>S3: 获取 doc12 完整内容
        CN->>S2: 获取 doc8 完整内容
    end

    S1-->>CN: doc1 内容
    S3-->>CN: doc12 内容
    S2-->>CN: doc8 内容

    CN-->>C: 最终搜索结果

这个两阶段设计的原因是性能:查询阶段只传输文档 ID 和排序值,数据量很小;取回阶段只获取最终需要的 Top-K 文档的完整内容,避免传输大量不需要的数据。


六、Elasticsearch 运维陷阱

分片膨胀(Shard Proliferation)

这是 Elasticsearch 集群最常见的运维问题。每个分片是一个 Lucene 索引,占用内存中的段元数据、文件句柄和线程资源。Elasticsearch 的集群状态(Cluster State)包含所有索引和分片的元数据,集群状态越大,master 节点的负担越重。

分片膨胀的典型原因:

  1. 每天创建新索引但不清理旧索引。 日志场景下如果没有配置 ILM,一年下来会产生 365 个索引。如果每个索引 5 个主分片 + 1 个副本,就是 3650 个分片。
  2. 索引粒度过细。 为每个租户创建独立索引,当租户数达到上万时,分片数爆炸。
  3. 创建索引时分片数设置过大。 预估数据量 10 GB 但设了 50 个主分片,每个分片只有 200 MB,远低于最优大小。

诊断命令:

# 查看集群分片总数
curl -s localhost:9200/_cluster/health | python3 -c \
  "import sys,json; d=json.load(sys.stdin); \
   print(f'Active shards: {d[\"active_primary_shards\"]} primary, {d[\"active_shards\"]} total')"

# 查看每个索引的分片数和大小
curl -s "localhost:9200/_cat/indices?v&s=store.size:desc&h=index,pri,rep,docs.count,store.size" \
  | head -20

# 查看每个节点的分片数
curl -s "localhost:9200/_cat/allocation?v&h=node,shards,disk.used,disk.avail,disk.percent"

解决方案:

// 1. 配置 ILM 自动管理索引生命周期(参见第三节的 ILM 配置示例)

// 2. 对已有的过多小分片,使用 shrink API 缩减分片数
POST /logs-2025-01-01/_shrink/logs-2025-01-01-shrunk
{
  "settings": {
    "index.number_of_shards": 1,
    "index.number_of_replicas": 1,
    "index.codec": "best_compression"
  }
}

// 3. 对多租户场景,用路由而不是独立索引来隔离数据
// 所有租户的数据放在同一个索引中,用 _routing  filter 隔离

映射爆炸(Mapping Explosion)

Elasticsearch 默认开启动态映射(Dynamic Mapping)——当写入文档包含新字段时,自动推断字段类型并添加到映射中。如果文档的字段名是动态生成的(比如用用户 ID 或时间戳作为字段名),映射会无限膨胀。

映射爆炸的后果:

防御措施:

{
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "title": { "type": "text" },
      "category": { "type": "keyword" },
      "price": { "type": "float" },
      "attributes": {
        "type": "object",
        "enabled": false
      }
    }
  },
  "settings": {
    "index.mapping.total_fields.limit": 500,
    "index.mapping.depth.limit": 5,
    "index.mapping.nested_fields.limit": 30
  }
}

dynamic 设为 strict 意味着写入包含未定义字段的文档会被拒绝,从根源上阻止映射膨胀。对于确实需要存储任意结构数据的字段(比如商品属性),用 "enabled": false 表示存储但不索引,不占用映射空间。

GC 问题

Elasticsearch 运行在 JVM 上,垃圾回收(Garbage Collection, GC)是一个持续的运维挑战。最危险的是 Stop-the-World 的 Full GC——在 Full GC 期间,整个 JVM 暂停,节点无法处理任何请求。如果 Full GC 持续时间超过 master 节点的故障检测超时时间(默认 30 秒),该节点会被认为已离线,触发分片重新分配。

常见的 GC 触发场景:

  1. 聚合查询返回大量桶。 一个 terms 聚合在基数很高的字段上执行(比如用户 ID,有几百万个不同值),会在堆内存中创建大量桶对象。
  2. 深度分页。 使用 from + size 做深度分页时,协调节点需要在内存中维护 from + size 个文档的排序信息。from=10000, size=10 意味着每个分片要返回 10010 条结果给协调节点。
  3. fielddata 加载。 对 text 字段做排序或聚合时,Elasticsearch 需要加载 fielddata 到堆内存——本质上是把倒排索引”反转”回正排索引。
// 避免深度分页:使用 search_after 代替 from+size
{
  "size": 10,
  "query": { "match_all": {} },
  "sort": [
    { "created_at": "desc" },
    { "_id": "asc" }
  ],
  "search_after": ["2025-03-15T10:30:00Z", "doc_12345"]
}
// 限制聚合桶数量
{
  "aggs": {
    "top_categories": {
      "terms": {
        "field": "category",
        "size": 100
      }
    }
  }
}
// 不要在基数超过 10 万的字段上做不加限制的 terms 聚合

JVM 配置建议:

# jvm.options
# 堆内存设为物理内存的一半,但不超过 30.5 GB
-Xms30g
-Xmx30g

# 使用 G1 垃圾回收器(ES 7.x 默认)
-XX:+UseG1GC
# G1 的目标暂停时间
-XX:MaxGCPauseMillis=200
# 初始化堆占用比例
-XX:InitiatingHeapOccupancyPercent=40

脑裂(Split Brain)

脑裂是分布式系统中的经典问题:网络分区导致集群被分成两个或多个子集,每个子集各自选举出一个 master 节点,各自独立运行。当网络恢复后,两个子集的数据已经分叉,无法自动合并。

Elasticsearch 7.0 之前使用 discovery.zen.minimum_master_nodes 参数来防止脑裂,需要手动设置为 (master-eligible 节点数 / 2) + 1。这个参数容易配错,配错就可能脑裂。

Elasticsearch 7.0 之后引入了新的选举机制,基于 cluster.initial_master_nodes 做初始引导,之后由系统自动维护仲裁机制(Quorum),不再需要手动设置 minimum_master_nodes。但仍然需要注意:

  1. master-eligible 节点数量必须是奇数(建议 3 个),这样网络分区时只有一个子集能形成多数派。
  2. cluster.initial_master_nodes 只在集群首次启动时使用,集群已经运行后应该从配置文件中移除这个参数,否则在某些异常情况下可能导致意外的集群重新引导。
  3. 专用 master 节点不做数据存储,避免因为数据操作导致的资源竞争影响 master 选举。
# master 节点配置(防止脑裂的最佳实践)
node.roles: [master]
cluster.name: production-cluster
node.name: master-1

# 仅在首次引导时使用,集群运行后注释掉
# cluster.initial_master_nodes: ["master-1", "master-2", "master-3"]

# 故障检测配置
discovery.cluster_formation_warning_timeout: 30s
cluster.fault_detection.leader_check.interval: 2s
cluster.fault_detection.leader_check.timeout: 10s
cluster.fault_detection.follower_check.interval: 2s
cluster.fault_detection.follower_check.timeout: 10s

七、搜索系统的整体架构

从查询到结果的完整链路

一个生产级搜索系统的完整链路远不止”在倒排索引里查一下”。从用户输入查询词到看到搜索结果,中间经历六个阶段:

graph TD
    A[用户输入查询] --> B[查询解析层]
    B --> C[查询改写层]
    C --> D[召回层]
    D --> E[粗排层]
    E --> F[精排层]
    F --> G[结果处理层]
    G --> H[返回搜索结果]

    B -->|分词、纠错、意图识别| B1[结构化查询]
    C -->|同义词扩展、查询扩展| C1[改写后查询]
    D -->|倒排索引 + 向量召回| D1[候选文档集]
    E -->|BM25 / 轻量级模型| E1[Top-N 候选]
    F -->|深度学习排序模型| F1[精排结果]
    G -->|去重、过滤、高亮、分页| G1[最终结果]

查询解析层。 用户输入的原始查询往往不是结构化的搜索表达式。查询解析负责:分词、拼写纠错(“ipohne” -> “iphone”)、意图识别(“北京天气”是要搜天气预报还是要搜关于天气的文章)。

查询改写层。 在用户查询的基础上做扩展:同义词扩展(“手机” -> “手机 OR 智能手机”)、下位词扩展(“水果” -> “水果 OR 苹果 OR 香蕉”)、查询放松(如果严格匹配结果太少,放松匹配条件)。

召回层。 从全量文档中快速筛选出一个候选集(通常几百到几千篇文档)。这一层要求速度极快,通常使用倒排索引做关键词召回,加上向量索引做语义召回。多路召回的结果做合并去重。

粗排层。 对召回层返回的几百到几千篇候选文档做一轮粗略排序,过滤到 Top-N(通常 100-200)。粗排模型通常比较轻量,比如逻辑回归或小型 GBDT 模型,能在毫秒级完成。

精排层。 对粗排层返回的 Top-N 文档用更复杂的模型做精细排序。在大型搜索系统中,精排层通常使用深度学习模型(比如 BERT-based 的交叉编码器),能捕捉查询和文档之间更深层的语义关系,但计算成本也更高。

结果处理层。 对精排结果做最终处理:去重(同一商品的多个 SKU 只展示一个)、业务过滤(下架商品、违规内容)、关键词高亮、摘要截取、分页。

缓存策略

搜索系统的缓存可以分为三个层次:

查询结果缓存(Query Result Cache)。 Elasticsearch 内置了分片级别的查询结果缓存,对于 size=0 的聚合查询效果显著。缓存键是完整的查询 DSL,任何分片上的数据变化都会使该分片的缓存失效。

// 显式控制查询缓存
{
  "size": 0,
  "query": {
    "bool": {
      "filter": [
        { "term": { "status": "published" } },
        { "range": { "created_at": { "gte": "2025-01-01" } } }
      ]
    }
  },
  "request_cache": true
}

过滤器缓存(Filter Cache / Node Query Cache)。 Elasticsearch 在节点级别缓存 filter 子句的结果(一个位图,标记哪些文档匹配)。频繁使用的过滤条件会被自动缓存。这也是为什么搜索查询应该尽量把不需要计算相关性得分的条件放在 filter 上下文而不是 must 上下文中——filter 上下文的结果可以被缓存,must 上下文不行。

{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "无线耳机" } }
      ],
      "filter": [
        { "term": { "brand": "Sony" } },
        { "range": { "price": { "gte": 200, "lte": 1000 } } },
        { "term": { "in_stock": true } }
      ]
    }
  }
}

应用层缓存。 在 Elasticsearch 外部,应用层可以用 Redis 或 Memcached 缓存热门查询的完整结果。电商搜索中,热门品类的搜索结果(“手机”、“笔记本电脑”)可以缓存 5-10 分钟,大幅降低 ES 集群的压力。

import redis
import hashlib
import json

class SearchCache:
    def __init__(self, redis_client, ttl=300):
        self.redis = redis_client
        self.ttl = ttl

    def _cache_key(self, query_params):
        """将查询参数序列化为缓存键"""
        serialized = json.dumps(query_params, sort_keys=True)
        return f"search:{hashlib.sha256(serialized.encode()).hexdigest()}"

    def get_or_search(self, query_params, search_func):
        key = self._cache_key(query_params)
        cached = self.redis.get(key)
        if cached:
            return json.loads(cached)

        result = search_func(query_params)
        self.redis.setex(key, self.ttl, json.dumps(result))
        return result

八、工程案例:电商商品搜索系统

场景与需求

一个中等规模电商平台的商品搜索系统,具体指标如下:

索引设计

PUT /products
{
  "settings": {
    "number_of_shards": 10,
    "number_of_replicas": 1,
    "index.refresh_interval": "5s",
    "analysis": {
      "analyzer": {
        "product_name_analyzer": {
          "type": "custom",
          "tokenizer": "ik_max_word",
          "filter": ["lowercase", "product_synonym_filter"]
        },
        "product_search_analyzer": {
          "type": "custom",
          "tokenizer": "ik_smart",
          "filter": ["lowercase", "product_synonym_filter"]
        }
      },
      "filter": {
        "product_synonym_filter": {
          "type": "synonym_graph",
          "synonyms_path": "analysis/product_synonyms.txt"
        }
      }
    }
  },
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "product_id": { "type": "keyword" },
      "title": {
        "type": "text",
        "analyzer": "product_name_analyzer",
        "search_analyzer": "product_search_analyzer",
        "fields": {
          "keyword": { "type": "keyword" }
        }
      },
      "brand": { "type": "keyword" },
      "category_path": { "type": "keyword" },
      "price": { "type": "scaled_float", "scaling_factor": 100 },
      "original_price": { "type": "scaled_float", "scaling_factor": 100 },
      "sales_count_30d": { "type": "integer" },
      "rating": { "type": "half_float" },
      "in_stock": { "type": "boolean" },
      "created_at": { "type": "date" },
      "updated_at": { "type": "date" },
      "tags": { "type": "keyword" },
      "specs": {
        "type": "nested",
        "properties": {
          "name": { "type": "keyword" },
          "value": { "type": "keyword" }
        }
      }
    }
  }
}

分片数的计算依据:5000 万商品,平均每个商品文档 2 KB,总数据量约 100 GB。按照每个分片 10-50 GB 的建议,10 个主分片,每个分片约 10 GB,处于合理范围。加上 1 个副本,总共 20 个分片。

查询构建

一个典型的商品搜索请求:用户搜索”无线蓝牙耳机”,筛选价格 100-500 元,品牌 Sony,按销量排序。

{
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "无线蓝牙耳机",
            "fields": ["title^3", "tags^2", "brand"],
            "type": "best_fields",
            "tie_breaker": 0.3
          }
        }
      ],
      "filter": [
        { "term": { "in_stock": true } },
        { "term": { "brand": "Sony" } },
        { "range": { "price": { "gte": 100, "lte": 500 } } }
      ]
    }
  },
  "sort": [
    { "sales_count_30d": { "order": "desc" } },
    { "_score": { "order": "desc" } }
  ],
  "highlight": {
    "fields": {
      "title": {
        "pre_tags": ["<em>"],
        "post_tags": ["</em>"],
        "fragment_size": 100,
        "number_of_fragments": 1
      }
    }
  },
  "aggs": {
    "brand_facets": {
      "terms": { "field": "brand", "size": 20 }
    },
    "price_ranges": {
      "range": {
        "field": "price",
        "ranges": [
          { "to": 100 },
          { "from": 100, "to": 300 },
          { "from": 300, "to": 500 },
          { "from": 500, "to": 1000 },
          { "from": 1000 }
        ]
      }
    },
    "avg_rating": {
      "avg": { "field": "rating" }
    }
  },
  "size": 20,
  "from": 0
}

关于 multi_matchboost 参数的设置:title 字段的 boost 设为 3,因为商品标题是用户搜索意图的最直接匹配源;tags 设为 2,是辅助维度;brand 不设 boost(默认 1),因为品牌名称通常通过精确筛选而非全文搜索来匹配。这些数值不是理论推导的结果,而是需要结合线上 A/B 测试逐步调优的经验参数。

集群拓扑

集群配置(中等规模电商搜索)
──────────────────────────────

Master 节点:     3 台  (8C 16G,  100GB SSD)
  - 专用 master,不存储数据

Data 节点:       6 台  (32C 64G, 2TB NVMe SSD)
  - 每台约 3-4 个分片
  - JVM 堆内存 30GB,剩余 34GB 给操作系统文件缓存

Coordinating:    2 台  (16C 32G)
  - 专用协调节点,处理请求路由和结果聚合

总分片:          20 个(10 主 + 10 副本)
数据总量:        ~100 GB(含副本 ~200 GB)
每节点数据:      ~33 GB

性能优化要点

利用文件系统缓存。 Lucene 大量使用内存映射文件(mmap),依赖操作系统的文件系统缓存来加速读取。JVM 堆内存只应占用物理内存的一半,剩余一半留给操作系统做文件缓存。如果堆内存设置过大,Lucene 段文件的缓存空间就被挤压,搜索性能反而下降。

预排序索引。 如果查询场景中最常见的排序方式是按时间倒序或按销量排序,可以在索引级别设置排序:

PUT /products
{
  "settings": {
    "index.sort.field": ["sales_count_30d", "created_at"],
    "index.sort.order": ["desc", "desc"]
  }
}

索引级别排序使得 Lucene 在执行 Top-K 查询时可以提前终止——一旦收集到足够的结果,就不需要遍历整个段。这对于”按销量排序取前 20 条”这类查询有显著的性能提升。代价是写入速度会有所下降,因为文档写入时需要按排序字段插入到正确的位置。

使用 keyword 字段做精确过滤。 品牌、分类、库存状态这些精确匹配的字段应该用 keyword 类型而不是 text 类型。keyword 字段不经过分析器,直接用原始值建立倒排索引,过滤效率更高。并且 keyword 字段的过滤结果可以被节点查询缓存缓存,而 text 字段的全文匹配不行。


九、工程案例:日志搜索系统

与商品搜索的关键差异

日志搜索和商品搜索在架构选型上有本质差异:

维度 商品搜索 日志搜索
数据量级 千万到亿级文档 日均 TB 级写入
写入模式 批量更新 + 实时增量 只追加,不更新
查询模式 关键词匹配 + 筛选 + 排序 时间范围 + 关键词 + 聚合
实时性要求 秒级可搜索 分钟级可接受
数据生命周期 长期保留 7-90 天后删除
相关性排序 核心需求 几乎不需要,按时间排序
成本敏感度 中等 高(数据量大,存储成本是主要开销)

日志系统的索引策略

// 索引模板
PUT _index_template/logs_template
{
  "index_patterns": ["logs-*"],
  "template": {
    "settings": {
      "number_of_shards": 3,
      "number_of_replicas": 1,
      "index.refresh_interval": "30s",
      "index.translog.durability": "async",
      "index.translog.sync_interval": "10s",
      "index.codec": "best_compression",
      "index.mapping.total_fields.limit": 200
    },
    "mappings": {
      "dynamic": "false",
      "properties": {
        "@timestamp": { "type": "date" },
        "level": { "type": "keyword" },
        "service": { "type": "keyword" },
        "host": { "type": "keyword" },
        "message": {
          "type": "text",
          "analyzer": "standard",
          "norms": false
        },
        "trace_id": { "type": "keyword" },
        "duration_ms": { "type": "integer" }
      }
    }
  }
}

注意 message 字段设置了 "norms": false。Norms 存储了字段长度归一化因子,用于相关性评分。日志搜索几乎不需要相关性排序(按时间排序更重要),禁用 norms 可以减少约 1 字节/文档/字段的存储开销,在日志量巨大的场景下效果显著。

数据管道

日志从产生到可搜索的完整管道:

应用 -> Filebeat -> Kafka -> Logstash -> Elasticsearch
                                |
                                v
                          (字段提取、格式转换、
                           条件过滤、GeoIP 解析)

Kafka 在这个管道中的作用是缓冲和削峰:当日志突发量远超 Elasticsearch 的写入能力时,Kafka 把超出部分暂存起,Logstash 按照 Elasticsearch 能接受的速率消费。没有 Kafka 的话,日志突发可能直接打垮 Elasticsearch 集群——写入队列堆积、GC 频繁、搜索请求超时。

成本优化

日志系统的存储成本往往是最大的运维支出。几个关键的成本控制手段:

冷热分离。 最近 24 小时的热数据放在 NVMe SSD 上,保证写入吞吐和查询速度;7 天以上的温数据降级到普通 SSD 或 HDD;30 天以上的冷数据转为可搜索快照(Searchable Snapshot),存储在对象存储(S3/MinIO)上,成本降低 80% 以上。

force merge。 温数据和冷数据不再有新写入,可以 force merge 成单个段,减少段数量,降低搜索开销和内存占用。

POST /logs-2025-03-01/_forcemerge?max_num_segments=1

source 过滤。 如果某些场景只需要搜索日志而不需要查看原始日志内容,可以禁用 _source 字段存储,节省约 30-50% 的存储空间。但这意味着无法查看原始文档,只能搜索和聚合——这个取舍需要根据业务需求谨慎决定。


十、搜索系统的可观测性

关键监控指标

搜索系统的监控不能只看 QPS 和延迟,还需要关注以下维度:

// 使用 _nodes/stats API 获取节点级别指标
GET _nodes/stats/indices,jvm,os,thread_pool

// 关键指标提取
{
  "search_latency_ms": "indices.search.query_time_in_millis / indices.search.query_total",
  "indexing_latency_ms": "indices.indexing.index_time_in_millis / indices.indexing.index_total",
  "gc_old_collection_count": "jvm.gc.collectors.old.collection_count",
  "gc_old_collection_time_ms": "jvm.gc.collectors.old.collection_time_in_millis",
  "heap_used_percent": "jvm.mem.heap_used_percent",
  "search_thread_pool_rejected": "thread_pool.search.rejected",
  "write_thread_pool_rejected": "thread_pool.write.rejected",
  "segment_count": "indices.segments.count",
  "fielddata_memory_bytes": "indices.fielddata.memory_size_in_bytes"
}

搜索线程池拒绝数(search.rejected)。 这个指标如果持续增长,说明搜索请求的到达速率超过了节点的处理能力。短期解决方案是增加协调节点或数据节点;长期方案是优化查询,减少每个请求的资源消耗。

段数量(segment_count)。 段数量过多说明段合并跟不上写入速度。需要检查合并策略配置,或者增加写入节点的资源。

fielddata 内存。 如果 fielddata 内存占用持续增长,说明有对 text 字段做排序或聚合的查询。应该改用 keyword 子字段,或者在 mapping 中为需要排序/聚合的 text 字段启用 doc_values 的 keyword 子字段。

慢查询日志

Elasticsearch 支持记录慢查询,帮助定位性能问题:

PUT /products/_settings
{
  "index.search.slowlog.threshold.query.warn": "5s",
  "index.search.slowlog.threshold.query.info": "2s",
  "index.search.slowlog.threshold.query.debug": "500ms",
  "index.search.slowlog.threshold.fetch.warn": "1s",
  "index.search.slowlog.threshold.fetch.info": "500ms",
  "index.search.slowlog.level": "info"
}

慢查询日志会记录完整的查询 DSL,方便复现和优化。常见的慢查询原因:

  1. 通配符查询打头(*phone)——无法利用 FST 词典的前缀查找,退化为全扫描。
  2. 嵌套聚合层数过多——每增加一层嵌套,计算量乘以内层桶的数量。
  3. 脚本查询(Script Query)——每个文档都要执行脚本,无法利用索引结构。
  4. 高基数字段的 terms 聚合——桶数量太多,内存和计算开销大。

十一、搜索系统演进方向

从关键词搜索到语义搜索

传统搜索依赖关键词的精确匹配,存在词汇鸿沟问题。用户搜”便宜的旅馆”,包含”经济型酒店”的文档不会被召回,即使语义完全匹配。

语义搜索的基本思路是:用预训练的语言模型把文本编码成向量,在向量空间中计算语义相似度。Elasticsearch 8.x 的向量搜索已经提供了基础能力,但在工程落地中还有几个挑战:

  1. 向量生成的成本。 把 5000 万商品的标题编码为 768 维向量,需要大量的 GPU 算力。如果使用外部 API(比如 OpenAI 的 Embedding API),成本更高。
  2. 向量更新的实时性。 商品信息变更后需要重新编码向量,这个延迟可能比纯文本索引更新的延迟大得多。
  3. 向量索引的存储开销。 768 维的 float32 向量,每个文档需要 3 KB。5000 万文档的向量数据约 150 GB,需要额外的存储和内存。

学习排序(Learning to Rank)

BM25 和手工调权重的方式有明显的上限。学习排序(Learning to Rank, LTR)用机器学习模型替代手工规则,从用户的点击行为数据中学习最优的排序策略。

LTR 的典型特征包括:

Elasticsearch 通过 LTR 插件支持学习排序,但模型训练和特征工程的工作量远大于部署本身。

搜索即服务的架构演进

当搜索系统的规模和复杂度增长到一定程度,往往会从单一的 Elasticsearch 集群演进为多层架构:

  1. 接入层:API 网关 + 限流 + 鉴权
  2. 查询理解层:独立的微服务,负责分词、纠错、意图识别、查询改写
  3. 召回层:可能包含多个引擎——Elasticsearch 做文本召回,向量数据库(Milvus/Pinecone)做语义召回,图数据库做关系召回
  4. 排序层:独立的模型服务,加载 LTR 模型做精细排序
  5. 结果处理层:业务逻辑、过滤、去重、高亮
  6. 缓存层:多级缓存,覆盖各个阶段

这种架构的好处是各层可以独立扩展和迭代,坏处是系统复杂度和运维成本大幅上升。是否需要这种架构取决于业务规模——日均搜索请求不到千万级、商品数不到千万级的场景,单一 Elasticsearch 集群加上简单的应用层逻辑通常就够了。


上一篇:流处理架构

下一篇:时序数据架构


参考资料

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13 · architecture

【系统架构设计百科】架构质量属性:不只是"高可用高性能"

需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。

2026-04-13 · architecture

【系统架构设计百科】告警策略:如何避免"狼来了"

大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。

2026-04-13 · architecture

【系统架构设计百科】复杂性管理:架构的核心战场

系统复杂性是架构腐化的根源——本文从 Brooks 的本质复杂性与偶然复杂性划分出发,结合认知负荷理论与 Parnas 的信息隐藏原则,系统阐述复杂性的来源、度量与控制手段,并给出可操作的架构策略


By .