🔍 设计一个搜索引擎系统:Google的秘密!

27 阅读10分钟

📖 开场:图书馆找书

想象你在图书馆找书 📚:

传统方法(遍历)

找《Java编程思想》这本书
    ↓
方法1:一排排书架找过去 → 太慢!❌
方法2:问管理员 → 管理员也要找 ❌

时间:30分钟 😱

索引卡片(搜索引擎)

查索引卡片:
"Java" → 第3排第5列第10本 ✅
"编程" → 第3排第5列第10本 ✅
"思想" → 第3排第5列第10本 ✅

时间:1分钟 🎉

这就是搜索引擎的原理:倒排索引!


🤔 核心概念

正排索引 vs 倒排索引

正排索引(正向)

文档 → 关键词

文档1:我爱Java编程
文档2:Java编程思想
文档3:Python编程入门

查询"Java":需要遍历所有文档 ❌

倒排索引(反向)

关键词 → 文档列表

Java → [文档1, 文档2]
编程 → [文档1, 文档2, 文档3]
思想 → [文档2]
Python → [文档3]

查询"Java":直接定位到[文档1, 文档2]

搜索引擎的核心流程

┌─────────────────────────────────────┐
│  1. 爬取文档(爬虫)                 │
│     - 网页爬取                      │
│     - 数据提取                      │
└─────────────┬───────────────────────┘
              │
              ↓
┌─────────────────────────────────────┐
│  2. 建立索引(索引器)               │
│     - 分词                          │
│     - 创建倒排索引                  │
│     - 计算权重(TF-IDF)            │
└─────────────┬───────────────────────┘
              │
              ↓
┌─────────────────────────────────────┐
│  3. 接收查询(搜索器)               │
│     - 查询分词                      │
│     - 匹配倒排索引                  │
│     - 计算相关性                    │
│     - 排序                          │
└─────────────┬───────────────────────┘
              │
              ↓
┌─────────────────────────────────────┐
│  4. 返回结果                        │
│     - Top10结果                     │
│     - 高亮关键词                    │
└─────────────────────────────────────┘

🎯 核心技术

技术1:分词 ✂️

中文分词

问题:中文没有空格

"我爱Java编程" → 如何分词?

方法1"我" "爱" "Java" "编" "程" ❌(意义不明确)

方法2(正确):
"我" "爱" "Java" "编程" ✅

方法3(更好):
"我" "爱" "Java" "Java编程" "编程" ✅(包含多种组合)

常用分词器

分词器说明示例
IK分词器中文分词,支持自定义词典"我爱Java" → "我"/"爱"/"Java"
jieba分词Python中文分词精确模式、全模式、搜索引擎模式
Standard分词器英文分词(按空格)"I love Java" → "I"/"love"/"Java"

ElasticSearch分词示例

POST _analyze
{
  "analyzer": "ik_smart",
  "text": "我爱Java编程"
}

响应:
{
  "tokens": [
    { "token": "我", "start_offset": 0, "end_offset": 1 },
    { "token": "爱", "start_offset": 1, "end_offset": 2 },
    { "token": "java", "start_offset": 2, "end_offset": 6 },
    { "token": "编程", "start_offset": 6, "end_offset": 8 }
  ]
}

技术2:倒排索引 📇

数据结构

倒排索引结构:

Term Dictionary(词典):
┌──────────┬─────────────────────┐
 关键词    Posting List指针    
├──────────┼─────────────────────┤
 Java       [1, 2, 5]         
 Python     [3, 4]            
 编程       [1, 2, 3, 4, 5]   
└──────────┴─────────────────────┘

Posting List(倒排列表):
Java  [
  {docId: 1, frequency: 3, positions: [0, 5, 10]},
  {docId: 2, frequency: 2, positions: [2, 8]},
  {docId: 5, frequency: 1, positions: [0]}
]

字段说明:
- docId: 文档ID
- frequency: 词频(TF)
- positions: 词位置(用于短语查询)

建立索引的过程

文档1:我爱Java编程
文档2:Java编程思想
文档3:Python编程入门

步骤1:分词
文档1["我", "爱", "Java", "编程"]
文档2["Java", "编程", "思想"]
文档3["Python", "编程", "入门"]

步骤2:建立倒排索引
我 → [1]
爱 → [1]
Java → [1, 2]
编程 → [1, 2, 3]
思想 → [2]
Python → [3]
入门 → [3]

步骤3:计算词频(TF)
Java在文档1中出现1次
Java在文档2中出现1

技术3:相关性评分(TF-IDF)📊

TF-IDF算法

TF(Term Frequency):词频

TF = 词在文档中出现的次数 / 文档总词数

例如:
文档1:"Java Java Python"
"Java"的TF = 2 / 3 = 0.67

IDF(Inverse Document Frequency):逆文档频率

IDF = log(总文档数 / 包含该词的文档数)

例如:
总文档:100个
包含"Java"的文档:10"Java"的IDF = log(100 / 10) = 1

TF-IDF

TF-IDF = TF × IDF

例如:
"Java"的TF-IDF = 0.67 × 1 = 0.67

意义

  • TF越高,说明词越重要
  • IDF越高,说明词越稀有(区分度高)
  • "的"、"是"等常见词IDF很低

BM25算法(ElasticSearch默认)

改进的TF-IDF

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

参数:
- k1:词频饱和度(默认1.2)
- b:长度归一化(默认0.75)

优点:
- 考虑文档长度
- 词频饱和(防止过度优化)

技术4:ElasticSearch实现 ⚡

安装和配置

# docker-compose.yml
version: '3'
services:
  elasticsearch:
    image: elasticsearch:7.17.0
    environment:
      - discovery.type=single-node
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    ports:
      - "9200:9200"
      - "9300:9300"
    volumes:
      - ./data:/usr/share/elasticsearch/data
  
  kibana:
    image: kibana:7.17.0
    ports:
      - "5601:5601"
    depends_on:
      - elasticsearch

创建索引

@Configuration
public class ElasticsearchConfig {
    
    @Value("${elasticsearch.host}")
    private String host;
    
    @Value("${elasticsearch.port}")
    private int port;
    
    @Bean
    public RestHighLevelClient esClient() {
        return new RestHighLevelClient(
            RestClient.builder(new HttpHost(host, port, "http"))
        );
    }
}
@Service
@Slf4j
public class ArticleSearchService {
    
    @Autowired
    private RestHighLevelClient esClient;
    
    private static final String INDEX_NAME = "articles";
    
    /**
     * ⭐ 创建索引
     */
    public void createIndex() throws IOException {
        CreateIndexRequest request = new CreateIndexRequest(INDEX_NAME);
        
        // ⭐ 设置mapping(字段映射)
        String mapping = "{\n" +
            "  "properties": {\n" +
            "    "id": { "type": "long" },\n" +
            "    "title": {\n" +
            "      "type": "text",\n" +
            "      "analyzer": "ik_max_word",\n" +  // ⭐ 使用IK分词器
            "      "search_analyzer": "ik_smart"\n" +
            "    },\n" +
            "    "content": {\n" +
            "      "type": "text",\n" +
            "      "analyzer": "ik_max_word",\n" +
            "      "search_analyzer": "ik_smart"\n" +
            "    },\n" +
            "    "author": { "type": "keyword" },\n" +
            "    "createTime": { "type": "date" },\n" +
            "    "viewCount": { "type": "integer" },\n" +
            "    "likeCount": { "type": "integer" }\n" +
            "  }\n" +
            "}";
        
        request.mapping(mapping, XContentType.JSON);
        
        // ⭐ 设置settings(索引配置)
        String settings = "{\n" +
            "  "number_of_shards": 3,\n" +      // 3个主分片
            "  "number_of_replicas": 2\n" +     // 2个副本
            "}";
        
        request.settings(settings, XContentType.JSON);
        
        // 创建索引
        CreateIndexResponse response = esClient.indices().create(request, RequestOptions.DEFAULT);
        
        log.info("索引创建成功: {}", response.isAcknowledged());
    }
    
    /**
     * ⭐ 添加文档
     */
    public void addDocument(Article article) throws IOException {
        IndexRequest request = new IndexRequest(INDEX_NAME);
        request.id(article.getId().toString());
        
        // 转换为JSON
        String json = new ObjectMapper().writeValueAsString(article);
        request.source(json, XContentType.JSON);
        
        // 添加文档
        IndexResponse response = esClient.index(request, RequestOptions.DEFAULT);
        
        log.info("文档添加成功: id={}, result={}", article.getId(), response.getResult());
    }
    
    /**
     * ⭐ 批量添加文档
     */
    public void bulkAddDocuments(List<Article> articles) throws IOException {
        BulkRequest bulkRequest = new BulkRequest();
        
        for (Article article : articles) {
            IndexRequest request = new IndexRequest(INDEX_NAME);
            request.id(article.getId().toString());
            
            String json = new ObjectMapper().writeValueAsString(article);
            request.source(json, XContentType.JSON);
            
            bulkRequest.add(request);
        }
        
        // ⭐ 批量执行
        BulkResponse response = esClient.bulk(bulkRequest, RequestOptions.DEFAULT);
        
        log.info("批量添加文档: count={}, hasFailures={}", 
            articles.size(), response.hasFailures());
    }
    
    /**
     * ⭐ 搜索文档
     */
    public List<Article> search(String keyword, int page, int size) throws IOException {
        SearchRequest request = new SearchRequest(INDEX_NAME);
        SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
        
        // ⭐ 构建查询条件
        MultiMatchQueryBuilder queryBuilder = QueryBuilders.multiMatchQuery(keyword)
            .field("title", 3.0f)   // ⭐ 标题权重3倍
            .field("content", 1.0f) // 内容权重1倍
            .type(MultiMatchQueryBuilder.Type.BEST_FIELDS);
        
        sourceBuilder.query(queryBuilder);
        
        // ⭐ 分页
        sourceBuilder.from((page - 1) * size);
        sourceBuilder.size(size);
        
        // ⭐ 排序(先按相关性,再按浏览量)
        sourceBuilder.sort("_score", SortOrder.DESC);
        sourceBuilder.sort("viewCount", SortOrder.DESC);
        
        // ⭐ 高亮
        HighlightBuilder highlightBuilder = new HighlightBuilder();
        highlightBuilder.field("title");
        highlightBuilder.field("content");
        highlightBuilder.preTags("<em style='color:red'>");
        highlightBuilder.postTags("</em>");
        sourceBuilder.highlighter(highlightBuilder);
        
        request.source(sourceBuilder);
        
        // 执行搜索
        SearchResponse response = esClient.search(request, RequestOptions.DEFAULT);
        
        // 解析结果
        List<Article> articles = new ArrayList<>();
        SearchHit[] hits = response.getHits().getHits();
        
        for (SearchHit hit : hits) {
            String json = hit.getSourceAsString();
            Article article = new ObjectMapper().readValue(json, Article.class);
            
            // ⭐ 设置高亮
            Map<String, HighlightField> highlightFields = hit.getHighlightFields();
            if (highlightFields.containsKey("title")) {
                String highlightTitle = highlightFields.get("title").fragments()[0].string();
                article.setTitle(highlightTitle);
            }
            if (highlightFields.containsKey("content")) {
                String highlightContent = highlightFields.get("content").fragments()[0].string();
                article.setContent(highlightContent);
            }
            
            // ⭐ 设置评分
            article.setScore(hit.getScore());
            
            articles.add(article);
        }
        
        log.info("搜索结果: keyword={}, total={}, took={}ms", 
            keyword, response.getHits().getTotalHits().value, response.getTook().getMillis());
        
        return articles;
    }
    
    /**
     * ⭐ 聚合查询(统计)
     */
    public Map<String, Long> aggregateByAuthor() throws IOException {
        SearchRequest request = new SearchRequest(INDEX_NAME);
        SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
        
        // ⭐ 聚合:按作者统计文章数
        TermsAggregationBuilder aggregation = AggregationBuilders
            .terms("author_count")
            .field("author")
            .size(10);
        
        sourceBuilder.aggregation(aggregation);
        sourceBuilder.size(0);  // 不需要返回文档
        
        request.source(sourceBuilder);
        
        // 执行聚合
        SearchResponse response = esClient.search(request, RequestOptions.DEFAULT);
        
        // 解析结果
        Terms terms = response.getAggregations().get("author_count");
        Map<String, Long> result = new HashMap<>();
        
        for (Terms.Bucket bucket : terms.getBuckets()) {
            result.put(bucket.getKeyAsString(), bucket.getDocCount());
        }
        
        return result;
    }
}

@Data
class Article {
    private Long id;
    private String title;
    private String content;
    private String author;
    private Date createTime;
    private Integer viewCount;
    private Integer likeCount;
    private Float score;  // 搜索评分
}

API接口

@RestController
@RequestMapping("/api/search")
public class SearchController {
    
    @Autowired
    private ArticleSearchService searchService;
    
    /**
     * ⭐ 搜索接口
     */
    @GetMapping
    public Result<PageResult<Article>> search(
            @RequestParam String keyword,
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "10") int size) {
        
        try {
            List<Article> articles = searchService.search(keyword, page, size);
            
            PageResult<Article> pageResult = new PageResult<>();
            pageResult.setList(articles);
            pageResult.setPage(page);
            pageResult.setSize(size);
            
            return Result.success(pageResult);
            
        } catch (IOException e) {
            log.error("搜索失败", e);
            return Result.fail("搜索失败");
        }
    }
    
    /**
     * 添加文章到索引
     */
    @PostMapping("/index")
    public Result<?> indexArticle(@RequestBody Article article) {
        try {
            searchService.addDocument(article);
            return Result.success("添加成功");
        } catch (IOException e) {
            log.error("添加失败", e);
            return Result.fail("添加失败");
        }
    }
}

技术5:高级功能 🚀

1️⃣ 拼音搜索

输入:"zhongguo"
匹配:"中国"

实现:拼音分词器
PUT /articles
{
  "settings": {
    "analysis": {
      "analyzer": {
        "pinyin_analyzer": {
          "tokenizer": "my_pinyin"
        }
      },
      "tokenizer": {
        "my_pinyin": {
          "type": "pinyin",
          "keep_first_letter": true,
          "keep_separate_first_letter": false,
          "keep_full_pinyin": true,
          "keep_original": true,
          "limit_first_letter_length": 16,
          "lowercase": true
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "pinyin_analyzer"
      }
    }
  }
}

2️⃣ 模糊搜索(容错)

输入:"Jave"(拼错了)
匹配:"Java"

实现:Fuzzy Query
// ⭐ 模糊查询(容错1个字符)
QueryBuilder queryBuilder = QueryBuilders.fuzzyQuery("title", "Jave")
    .fuzziness(Fuzziness.ONE);  // 允许1个字符差异

3️⃣ 搜索建议(自动补全)

输入:"Ja"
建议:["Java", "JavaScript", "Jakarta"]

实现:Completion Suggester
PUT /articles
{
  "mappings": {
    "properties": {
      "suggest": {
        "type": "completion"
      }
    }
  }
}
// ⭐ 搜索建议
SuggestBuilder suggestBuilder = new SuggestBuilder();
suggestBuilder.addSuggestion("title-suggest", 
    SuggestBuilders.completionSuggestion("suggest")
        .prefix("Ja")
        .size(10));

SearchRequest request = new SearchRequest(INDEX_NAME);
request.source(new SearchSourceBuilder().suggest(suggestBuilder));

4️⃣ 短语搜索(精确匹配)

输入:"Java编程"
匹配:包含"Java编程"这个短语的文档(顺序不能变)

实现:Match Phrase Query
// ⭐ 短语查询
QueryBuilder queryBuilder = QueryBuilders.matchPhraseQuery("content", "Java编程");

5️⃣ 范围查询

查询:浏览量 > 1000 的文章

实现:Range Query
// ⭐ 范围查询
QueryBuilder queryBuilder = QueryBuilders.rangeQuery("viewCount")
    .gte(1000);  // Greater Than or Equal

📊 架构总结

        搜索引擎系统架构

┌──────────────────────────────────────┐
│         数据源                        │
│  - MySQL数据库                       │
│  - 爬虫数据                          │
└─────────────┬────────────────────────┘
              │
              ↓ 同步/导入
┌──────────────────────────────────────┐
│      ElasticSearch集群                │
│                                      │
│  节点1        节点2        节点3      │
│  ├─分片1-主  ├─分片2-主   ├─分片3-主 │
│  ├─分片2-副  ├─分片3-副   ├─分片1-副 │
│  └─分片3-副  └─分片1-副   └─分片2-副 │
│                                      │
│  功能:                              │
│  - 倒排索引                          │
│  - 分词                              │
│  - 相关性评分                        │
│  - 高亮                              │
└─────────────┬────────────────────────┘
              │
              ↓
┌──────────────────────────────────────┐
│         应用服务器                    │
│  - 搜索API                           │
│  - 结果排序                          │
│  - 缓存热点查询                      │
└─────────────┬────────────────────────┘
              │
              ↓
┌──────────────────────────────────────┐
│           客户端                      │
│  - 搜索框                            │
│  - 搜索建议                          │
│  - 结果展示                          │
└──────────────────────────────────────┘

🎓 面试题速答

Q1: 倒排索引是什么?

A: 倒排索引 = 从关键词到文档的映射

正排索引:文档 → 关键词
倒排索引:关键词 → 文档列表

例如:
Java → [文档1, 文档2, 文档5]
Python → [文档3, 文档4]

优点

  • 搜索快(O(1)定位)
  • 适合全文检索

Q2: TF-IDF是什么?

A: TF-IDF = 词频-逆文档频率

TF(Term Frequency):词频
TF = 词在文档中出现的次数 / 文档总词数

IDF(Inverse Document Frequency):逆文档频率
IDF = log(总文档数 / 包含该词的文档数)

TF-IDF = TF × IDF

意义

  • TF高:词在文档中很重要
  • IDF高:词很稀有,区分度高
  • "的"、"是"等常见词IDF很低

Q3: ElasticSearch的分片和副本是什么?

A: 分片(Shard)

  • 索引水平拆分
  • 每个分片是独立的索引
  • 支持并发查询

副本(Replica)

  • 分片的备份
  • 提高可用性(主分片挂了,副本顶上)
  • 提高查询性能(分担查询压力)
配置:
- number_of_shards: 3(3个主分片)
- number_of_replicas: 2(每个主分片有2个副本)

总分片数:3个主 + 3×2个副 = 9个分片

Q4: 如何实现搜索高亮?

A: HighlightBuilder

HighlightBuilder highlightBuilder = new HighlightBuilder();
highlightBuilder.field("title");
highlightBuilder.field("content");
highlightBuilder.preTags("<em style='color:red'>");
highlightBuilder.postTags("</em>");

sourceBuilder.highlighter(highlightBuilder);

效果

搜索"Java":
标题:《<em style='color:red'>Java</em>编程思想》

Q5: 如何优化搜索性能?

A: 五种优化

  1. 增加分片数

    • 并发查询
    • 分散压力
  2. 增加副本数

    • 分担查询压力
    • 提高可用性
  3. 缓存热点查询

    • Redis缓存搜索结果
    • 减少ES压力
  4. 使用过滤(Filter)而不是查询

    • Filter可以缓存
    • 性能更好
  5. 限制返回字段

    sourceBuilder.fetchSource(new String[]{"title", "author"}, null);
    

Q6: ElasticSearch vs Solr?

A: 对比

特性ElasticSearchSolr
易用性⭐⭐⭐ 简单⭐⭐ 中等
实时性⭐⭐⭐ 近实时⭐⭐ 延迟高
分布式⭐⭐⭐ 天生分布式⭐⭐ 需要ZooKeeper
社区⭐⭐⭐ 活跃⭐⭐ 较活跃
性能⭐⭐⭐ 高⭐⭐⭐ 高

推荐:ElasticSearch ⭐⭐⭐


🎬 总结

       搜索引擎核心技术

┌────────────────────────────────────┐
│ 倒排索引                           │
│ - 关键词 → 文档列表                │
│ - 快速定位                         │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 分词                               │
│ - IK分词器(中文)                 │
│ - Standard分词器(英文)           │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 相关性评分                         │
│ - TF-IDF                           │
│ - BM25(ElasticSearch默认)        │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ ElasticSearch                      │
│ - 分布式                           │
│ - 近实时                           │
│ - 高性能                           │
└────────────────────────────────────┘

    ElasticSearch是最佳选择!✅

🎉 恭喜你!

你已经完全掌握了搜索引擎系统的设计!🎊

核心要点

  1. 倒排索引:关键词→文档列表
  2. 分词:IK分词器(中文)
  3. 相关性评分:TF-IDF、BM25
  4. ElasticSearch:分布式、高性能

下次面试,这样回答

"搜索引擎的核心是倒排索引,将关键词映射到文档列表,实现快速检索。

我们使用ElasticSearch实现,配置IK分词器进行中文分词。创建索引时,设置3个主分片和2个副本,标题字段权重设为3倍,内容字段权重1倍。

搜索时使用MultiMatchQuery在标题和内容中查询,按BM25算法计算相关性评分,并按评分和浏览量排序。结果支持高亮显示,将匹配的关键词用红色标注。

性能优化方面,通过增加分片数支持并发查询,使用Redis缓存热点查询结果,限制返回字段减少网络传输。

我们项目的文章搜索系统采用ElasticSearch,支持千万级文档,平均响应时间50ms,搜索准确率95%以上。"

面试官:👍 "很好!你对搜索引擎系统的设计理解很深刻!"


本文完 🎬

上一篇: 202-设计一个文件上传和存储服务.md
下一篇: 204-设计一个新闻Feed流系统.md

作者注:写完这篇,我都想去Google做搜索引擎了!🔍
如果这篇文章对你有帮助,请给我一个Star⭐!