跳到内容

元数据卡

  • 前置:缓存系统(第19章)、B+树与索引(第4章)、字符串匹配(Vol 2 ch11)
  • 关键词:倒排索引、分词、相关性、Elasticsearch、近实时搜索
  • 代码语言:Java / Shell / REST

你的进度

快取桌解决了"按 ID 查一件宝物"的问题。但问题很快升级了——守卫说"找一下所有描述里带'龙鳞'的宝物"。你发现 B+树索引只在"按 ID"或"按等级排序"时有用,它不知道文档里写了什么。数据堡垒需要一个全新的工具——不是查单条数据,而是全文检索

你的任务

这一章带你理解搜索引擎的核心机制——以及它为什么不能替代数据库。

你会学到:

  1. 数据库索引为什么搜不了关键词
  2. 倒排索引——搜索引擎的基石
  3. 分词与相关性排序
  4. 近实时搜索与索引刷新
  5. 用 Elasticsearch 搭一个搜索服务

破局 · 溯源

1. 数据库的 LIKE 为什么不够用

快取桌解决了按 ID 查宝物的问题,但守卫的新需求更难了。"找一下所有描述里带‘龙鳞’的宝物。"老陈摊开一本厚厚的宝物手册——"B+树索引只在按 ID 或者按等级排序时有用。它不知道文档里写了什么。"

守卫的需求很简单:搜一下所有宝物描述。你用 SQL 写出来也不复杂:

sql
SELECT * FROM treasures WHERE description LIKE '%龙鳞%';

但这条 SQL 在几百万条宝物上跑一次——耗时 3 秒。而且它只能做精确子串匹配,不能处理同义词("龙鳞"和"龙的鳞片")、不能按相关性排序、不能处理拼写差异。

数据库索引为什么帮不上忙? B+树索引从左到右匹配前缀。LIKE '%龙鳞%' 用的是前导通配符,B+树不知道从哪里开始找——只能全表扫描。

搜索引擎的核心思想:把文档拆成词,建一个"词 → 文档列表"的索引。查的时候直接在词表里找"龙鳞"这个词,立刻拿到所有包含它的文档 ID。

2. 倒排索引——倒过来看的书

正排索引是"文档 → 词":打开一篇文档,看它包含了哪些词。倒排索引反过来——"词 → 文档":给定一个词,找到所有包含它的文档。

正排索引(数据库的思维方式):
 doc1: "地龙鳞片是一种稀有材料"
   → [地, 龙, 鳞片, 是, 一种, 稀有, 材料]
 doc2: "火焰结晶产自火山深处"
   → [火, 火焰, 结晶, 产自, 火山, 深处]

倒排索引(搜索引擎的思维方式):
 "龙"   → [doc1]
 "鳞片" → [doc1]
 "结晶" → [doc2]
 "火山" → [doc2]
 "稀有" → [doc1]

建倒排索引需要两步:

  1. 分词(Tokenization):把一段文字切成有意义的词
  2. 建立倒排表:每个词指向一组文档 ID
java
// 一个极简的倒排索引实现
public class InvertedIndex {
    // 核心数据结构:词 → 文档ID列表
    private Map<String, List<Integer>> index = new HashMap<>();
    private int nextDocId = 0;
    // 文档ID → 原始文档内容
    private Map<Integer, String> documents = new HashMap<>();

    // 添加一篇文档,建立它的倒排索引
    public void addDocument(String text) {
        int docId = nextDocId++;
        documents.put(docId, text);
        // 简单分词:按空格和非字母字符拆分
        String[] tokens = text.toLowerCase().split("[^a-zA-Z0-9\u4e00-\u9fff]+");
        for (String token : tokens) {
            if (token.isEmpty()) continue;
            index.computeIfAbsent(token, k -> new ArrayList<>()).add(docId);
        }
    }

    // 搜索包含某个词的所有文档
    public List<String> search(String word) {
        String key = word.toLowerCase();
        List<Integer> docIds = index.getOrDefault(key, Collections.emptyList());
        return docIds.stream()
            .map(id -> documents.get(id))
            .collect(Collectors.toList());
    }
}

// 使用
InvertedIndex idx = new InvertedIndex();
idx.addDocument("地龙鳞片是一种稀有材料");
idx.addDocument("火焰结晶产自火山深处");

List<String> results = idx.search("龙鳞");
// 返回 ["地龙鳞片是一种稀有材料"]

语言:Java 21 如何运行:这是一个教学级倒排索引。addDocument 建立索引,search 查词。computeIfAbsent 是 Java 8 的惯用写法——不存在就创建空列表。 预期输出:搜索"龙鳞"返回第一篇文档。 你试试:添加更多文档,尝试搜索一个出现在多篇文档里的词。

** Python 差异**

Python 的 collections.defaultdict(list) 在功能上等价于 computeIfAbsent,写法更简洁:

python
from collections import defaultdict
index = defaultdict(list)
index[token].append(doc_id)  # 不需要先检查 key 是否存在

3. 分词——从连续文字到有意义的词

上面的分词极其粗糙。中文分词尤其难——"地龙鳞片"在中文里是一个词还是三个词?英文按空格分就够了,中文需要更复杂的策略。

常见分词策略:

策略原理例子:"地龙鳞片是稀有材料"
单字分每个字一个词地 / 龙 / 鳞 / 片 / 是 / 稀 / 有 / 材 / 料
二元分每两个字一组地龙 / 龙鳞 / 鳞片 / 片是 / 稀有 / 有材 / 材料
词典分按词典匹配最长词地龙 / 鳞片 / 是 / 稀有 / 材料
机器学习分模型判断词边界地龙鳞片 / 是 / 稀有 / 材料

搜索引擎(如 Elasticsearch)通常用 词典分 + 自定义词典 的组合,也支持用户添加领域词(比如把"地龙鳞片"作为一个不可拆分的关键词)。

4. 相关性排序——不仅找到,还要排好

找到文档只是第一步。用户想要的是"最相关的结果排在最前面"。

TF-IDF 是最经典的相关性算法:

TF(词频) = 这个词在这篇文档里出现了多少次 / 这篇文档的总词数
IDF(逆文档频率)= log(总文档数 / 包含这个词的文档数)

TF-IDF = TF * IDF

如果一个词在单篇文档中出现多次(TF 高),但在整个文档集合中很少出现(IDF 高),那它很可能就是这篇文档的关键词。

BM25(Elasticsearch 的默认算法)是 TF-IDF 的改进版,引入了文档长度归一化:

BM25 = IDF * (TF * (k1 + 1)) / (TF + k1 * (1 - b + b * 文档长度/平均文档长度))

其中 k1 和 b 是调参系数。默认为 k1=1.2, b=0.75,大多数场景不需要改。

bash
# 在 Elasticsearch 中搜索并查看相关性分数
GET /treasures/_search
{
  "query": {
    "match": {
      "description": "地龙鳞片"
    }
  }
}

# 返回结果中的 _score 字段就是相关性分数
# "_score": 1.234567

5. 近实时搜索与索引刷新

当你往搜索引擎里加了一篇新文档,它不会立刻出现在搜索结果中。数据从"写入"到"可被搜索"之间有一个延迟——这就是近实时(Near Real-Time, NRT)

流程是这样的:

写入请求 → Buffer → (触发 refresh)→ 新 Segment → (产生新文档)→ 可被搜索
                    ↑ 默认每秒自动 refresh 一次
bash
# Elasticsearch 中,你可以手动触发 refresh
POST /treasures/_refresh

# 或者修改刷新间隔(生产环境不建议低于 1s)
PUT /treasures/_settings
{
  "index": {
    "refresh_interval": "5s"
  }
}

为什么不能像数据库一样实时? 搜索引擎的写入和查询是两个优化方向不同的路径。写入要批量、顺序、不可变(Segment);查询要在多个 Segment 上并行搜。为了批量写入的性能,搜索引擎牺牲了写入后的即时可见性。这是搜索引擎和数据库最大的设计差异之一。

6. 用 Elasticsearch 搭一个搜索服务

bash
# 启动 Elasticsearch
docker run -d --name atlas-search -p 9200:9200 -e "discovery.type=single-node" elasticsearch:8.10.0

# 创建一个索引(相当于数据库中的"表")
PUT /treasures
{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 0
  },
  "mappings": {
    "properties": {
      "name":       { "type": "text", "analyzer": "standard" },
      "description":{ "type": "text", "analyzer": "standard" },
      "level":      { "type": "keyword" },
      "price":      { "type": "integer" }
    }
  }
}

# 添加几条文档
POST /treasures/_doc
{ "name": "地龙鳞片", "description": "从地龙身上采集的稀有材料", "level": "金级", "price": 5000 }

POST /treasures/_doc
{ "name": "火焰结晶", "description": "火山深处形成的能量结晶", "level": "银级", "price": 1200 }

POST /treasures/_doc
{ "name": "龙骨", "description": "远古巨龙的遗骨,蕴含强大魔力", "level": "金级", "price": 8000 }

# 搜索:找所有描述里带"龙"的宝物
GET /treasures/_search
{
  "query": {
    "match": {
      "description": "龙"
    }
  }
}
# 返回地龙鳞片、龙骨——按相关性排序

如何运行:打开终端,用 docker run 启动 ES,然后用 curl 或任何 REST 客户端发送上面的请求。 预期输出:添加文档后,搜索"龙"会返回地龙鳞片(score 更高)和龙骨。 你试试:改搜索词为"稀有",观察结果;加更多宝物,观察相关性分数变化。


常见陷阱

陷阱一:把 ES 当主数据库用

Elasticsearch 是搜索和分析引擎,不是事务性数据库。它没有 ACID 事务,数据写入后到可搜索之间有延迟。正确的架构是:数据库写数据 → 同步到 ES → ES 只负责搜索。

陷阱二:忘记分词

搜索引擎的分词策略决定了用户搜什么词能找到你的文档。如果宝物描述里写了"地龙鳞片"但用户搜"龙鳞"搜不到,分词策略需要调整。在生产系统中,维护一份领域词典(domain-specific vocabulary)是搜索引擎运维的核心工作之一。

陷阱三:索引映射(Mapping)设错字段类型

text 类型会被分词,适合全文搜索;keyword 类型不分词,适合精确匹配(如等级、状态、标签)。把描述设成 keyword 会导致全文搜索失效;把等级设成 text 会导致聚合统计不准。

陷阱四:相关性分数不可解释就不信任

如果一个搜索结果排到第一页但你不知道为什么,尝试加 explain: true 看看分数构成:

json
GET /treasures/_search
{
  "explain": true,
  "query": { "match": { "description": "龙" } }
}

通关挑战

  • 热身:用自己的话解释倒排索引和正排索引的区别。
  • 挑战:用你熟悉的语言实现一个支持中文二元分词的倒排索引。加入文档后,搜索一个词被哪些文档包含。
  • 观察:启动 Elasticsearch,创建一个索引,添加几条文档。用 GET /treasures/_search 搜索,观察 _score 的变化。添加更多文档后搜索相同的词,观察 IDF 的变化——包含某词的文档越多,该词对搜索结果的区分度就越低。
  • 排障:你发现搜"龙鳞"能搜到所有含有"龙"或"鳞"的文档,但你只想搜同时包含"龙"和"鳞"的文档。怎么做?(提示:match_phrase 查询)

验收标准

读完后你能:

  • 解释数据库索引和倒排索引的本质差异
  • 手动建一个极简倒排索引并搜索
  • 理解分词、TF-IDF、BM25 的基本原理
  • 说出近实时(NRT)搜索的延迟来源
  • 在 Elasticsearch 中创建索引、添加文档、执行搜索

常见卡点

  • 倒排索引为什么不支持更新:搜索引擎的底层存储是不可变 Segment。更新操作就是"标记旧文档删除 + 写入新文档到一个新 Segment"。后台的 Merge 操作会定期合并 Segment 并真正删除旧数据。这种设计是为了最大化写入吞吐量(顺序写 + 批量合并)。
  • 搜索引擎和数据库能否合一:有些系统试图做(如 Elasticsearch 自己也在加事务和 SQL 支持),但两种优化方向天然冲突——数据库优化点查和更新,搜索引擎优化关键词检索和聚合。
  • 中文分词怎么做:生产环境用 ik(Elasticsearch 的中文分词插件)或 jieba(Python 生态)。

现在不需要理解

  • Elasticsearch 的分布式搜索和 Aggregation 实现细节
  • 不同分词器(Standard / ICU / IK / NGram)的配置调优
  • 索引的刷新机制(Translog、Flush、Merge)的底层实现
  • 搜索引擎的性能压测和索引优化

旅人笔记

搜索引擎的核心是倒排索引——从"词找文档"而不是"文档找词"。分词决定了用户能不能搜到,BM25 决定了搜到后排得好不好,近实时(NRT)让新数据在几秒内可搜而不是几小时。数据库和搜索引擎是互补关系,不是替代关系:数据库管存储,搜索引擎管检索

下一站预告

数据堡垒的存储层已经建完了——从 SQL 到事务到搜索到缓存,你现在有了一整套数据基础设施。下一卷你将进入工匠之都(Vol 6),学习如何把这些工具组织成一个可维护的软件系统。但在此之前,你想不想知道另一个"快取桌"的问题——

Built with VitePress | Software Systems Atlas