Skip to content

如何设计搜索引擎

一、问题描述

1.1 业务背景

搜索引擎是信息检索的核心技术,广泛应用于:

  • 网站搜索:Google、百度、必应
  • 站内搜索:电商商品搜索、文档搜索
  • 企业搜索:知识库、日志搜索

核心价值:从海量数据中快速找到相关信息

1.2 核心功能

基础功能

  1. 全文检索:根据关键词搜索文档
  2. 相关性排序:最相关的结果排在前面
  3. 分页展示:支持翻页查看更多结果

进阶功能

  1. 搜索提示:输入时自动补全
  2. 拼写纠错:自动纠正拼写错误
  3. 高亮显示:高亮匹配关键词
  4. 相关搜索:推荐相关搜索词

1.3 技术挑战

数据规模

  • Google索引:数千亿网页
  • 淘宝商品:数十亿商品
  • 如何快速检索?

响应速度

  • 搜索响应时间要求:<100ms
  • 如何做到毫秒级返回?

相关性排序

  • 如何判断哪个结果更相关?
  • TF-IDF、BM25、PageRank

1.4 面试考察点

  • 倒排索引:搜索引擎核心数据结构
  • 分词算法:中文分词的挑战
  • 相关性算法:TF-IDF、BM25
  • Elasticsearch:分布式搜索引擎
  • 性能优化:缓存、索引分片

二、核心概念

2.1 倒排索引(Inverted Index)

正排索引(文档 → 词):

Doc1: "搜索引擎原理"
Doc2: "搜索算法设计"
Doc3: "引擎优化技术"

倒排索引(词 → 文档):

搜索 → [Doc1, Doc2]
引擎 → [Doc1, Doc3]
原理 → [Doc1]
算法 → [Doc2]
设计 → [Doc2]
优化 → [Doc3]
技术 → [Doc3]

倒排表结构

词典(Dictionary)        倒排列表(Posting List)
-------------------------  ---------------------------------
搜索                    →  [Doc1:pos1, Doc2:pos1]
引擎                    →  [Doc1:pos2, Doc3:pos1]
...

2.2 TF-IDF算法

TF(Term Frequency):词频

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

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

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

TF-IDF得分

Score = TF × IDF

示例

文档1:"搜索引擎原理 搜索引擎设计"
文档2:"数据库设计原理"

查询:"搜索引擎"

词"搜索":
- TF(Doc1) = 2/4 = 0.5
- IDF = log(2/1+1) = 0.3
- TF-IDF = 0.5 × 0.3 = 0.15

词"引擎":
- TF(Doc1) = 2/4 = 0.5
- IDF = log(2/1+1) = 0.3
- TF-IDF = 0.5 × 0.3 = 0.15

Doc1总分 = 0.15 + 0.15 = 0.3
Doc2总分 = 0(不包含"搜索"和"引擎")

结果:Doc1排名第一

三、系统设计

3.1 架构图

mermaid
graph TB
    subgraph 索引构建
        A[原始文档] --> B[分词器]
        B --> C[倒排索引构建器]
        C --> D[倒排索引存储]
    end
    
    subgraph 搜索流程
        E[用户查询] --> F[查询解析]
        F --> G[分词]
        G --> H[索引检索]
        H --> I[相关性排序]
        I --> J[返回结果]
    end
    
    D --> H

3.2 数据结构设计

词典表

sql
CREATE TABLE dictionary (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    term VARCHAR(100) UNIQUE NOT NULL COMMENT '词项',
    doc_freq INT NOT NULL COMMENT '文档频率',
    posting_offset BIGINT COMMENT '倒排列表偏移量',
    KEY idx_term (term)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

倒排列表

sql
CREATE TABLE posting_list (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    term_id BIGINT NOT NULL COMMENT '词项ID',
    doc_id BIGINT NOT NULL COMMENT '文档ID',
    term_freq INT NOT NULL COMMENT '词频',
    positions JSON COMMENT '位置列表',
    KEY idx_term_doc (term_id, doc_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

文档表

sql
CREATE TABLE documents (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    title VARCHAR(500) NOT NULL,
    content TEXT NOT NULL,
    url VARCHAR(1000),
    doc_length INT COMMENT '文档长度',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    KEY idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

四、核心实现

4.1 Go实现

点击查看完整实现
go
package searchengine

import (
    "math"
    "sort"
    "strings"
)

// SearchEngine 搜索引擎
type SearchEngine struct {
    dictionary   map[string]*Term          // 词典
    postingLists map[string][]*Posting    // 倒排列表
    documents    map[int64]*Document       // 文档集合
    totalDocs    int                       // 文档总数
}

// Term 词项
type Term struct {
    Term    string
    DocFreq int // 包含该词的文档数
}

// Posting 倒排列表项
type Posting struct {
    DocID     int64
    TermFreq  int   // 词频
    Positions []int // 词在文档中的位置
}

// Document 文档
type Document struct {
    ID        int64
    Title     string
    Content   string
    DocLength int // 文档长度(词数)
}

// SearchResult 搜索结果
type SearchResult struct {
    DocID int64
    Title string
    Score float64
}

// NewSearchEngine 创建搜索引擎
func NewSearchEngine() *SearchEngine {
    return &SearchEngine{
        dictionary:   make(map[string]*Term),
        postingLists: make(map[string][]*Posting),
        documents:    make(map[int64]*Document),
    }
}

// BuildIndex 构建索引
func (se *SearchEngine) BuildIndex(docs []*Document) {
    for _, doc := range docs {
        se.addDocument(doc)
    }
}

// addDocument 添加文档到索引
func (se *SearchEngine) addDocument(doc *Document) {
    // 分词
    terms := tokenize(doc.Content)
    doc.DocLength = len(terms)
    
    // 存储文档
    se.documents[doc.ID] = doc
    se.totalDocs++
    
    // 统计词频和位置
    termFreqs := make(map[string]int)
    termPositions := make(map[string][]int)
    
    for pos, term := range terms {
        termFreqs[term]++
        termPositions[term] = append(termPositions[term], pos)
    }
    
    // 构建倒排索引
    for term, freq := range termFreqs {
        // 更新词典
        if _, exists := se.dictionary[term]; !exists {
            se.dictionary[term] = &Term{
                Term:    term,
                DocFreq: 0,
            }
        }
        se.dictionary[term].DocFreq++
        
        // 添加到倒排列表
        posting := &Posting{
            DocID:     doc.ID,
            TermFreq:  freq,
            Positions: termPositions[term],
        }
        se.postingLists[term] = append(se.postingLists[term], posting)
    }
}

// Search 搜索
func (se *SearchEngine) Search(query string, limit int) []*SearchResult {
    // 1. 查询分词
    queryTerms := tokenize(query)
    
    // 2. 获取候选文档(包含任一查询词的文档)
    candidateDocs := se.getCandidateDocs(queryTerms)
    
    // 3. 计算BM25得分
    results := make([]*SearchResult, 0)
    for docID := range candidateDocs {
        score := se.calculateBM25(queryTerms, docID)
        
        doc := se.documents[docID]
        results = append(results, &SearchResult{
            DocID: docID,
            Title: doc.Title,
            Score: score,
        })
    }
    
    // 4. 按得分排序
    sort.Slice(results, func(i, j int) bool {
        return results[i].Score > results[j].Score
    })
    
    // 5. 返回Top N
    if limit > len(results) {
        limit = len(results)
    }
    
    return results[:limit]
    }
    
// getCandidateDocs 获取候选文档
func (se *SearchEngine) getCandidateDocs(queryTerms []string) map[int64]bool {
    candidates := make(map[int64]bool)
    
    for _, term := range queryTerms {
        if postings, exists := se.postingLists[term]; exists {
            for _, posting := range postings {
                candidates[posting.DocID] = true
            }
        }
    }
    
    return candidates
}

// calculateBM25 计算BM25得分
func (se *SearchEngine) calculateBM25(queryTerms []string, docID int64) float64 {
    const (
        k1 = 1.5  // 词频饱和参数
        b  = 0.75 // 长度归一化参数
    )
    
    doc := se.documents[docID]
    avgDocLength := se.calculateAvgDocLength()
    score := 0.0
    
    for _, term := range queryTerms {
        // 计算IDF
        idf := se.calculateIDF(term)
        
        // 获取词频
        tf := se.getTermFreq(term, docID)
        
        // BM25公式
        numerator := tf * (k1 + 1)
        denominator := tf + k1*(1-b+b*float64(doc.DocLength)/avgDocLength)
        
        score += idf * (numerator / denominator)
    }
    
    return score
        }
        
// calculateIDF 计算IDF
func (se *SearchEngine) calculateIDF(term string) float64 {
    termInfo, exists := se.dictionary[term]
    if !exists {
        return 0.0
    }
    
    // IDF = log((N - df + 0.5) / (df + 0.5) + 1)
    n := float64(se.totalDocs)
    df := float64(termInfo.DocFreq)
    
    return math.Log((n-df+0.5)/(df+0.5) + 1)
}

// getTermFreq 获取词频
func (se *SearchEngine) getTermFreq(term string, docID int64) float64 {
    postings, exists := se.postingLists[term]
    if !exists {
        return 0.0
    }
    
    for _, posting := range postings {
        if posting.DocID == docID {
            return float64(posting.TermFreq)
        }
    }
    
    return 0.0
}

// calculateAvgDocLength 计算平均文档长度
func (se *SearchEngine) calculateAvgDocLength() float64 {
    if se.totalDocs == 0 {
        return 0
    }
    
    totalLength := 0
    for _, doc := range se.documents {
        totalLength += doc.DocLength
    }
    
    return float64(totalLength) / float64(se.totalDocs)
}

// tokenize 分词(简化版,实际应使用jieba等)
func tokenize(text string) []string {
    // 简单按空格分词
    text = strings.ToLower(text)
    return strings.Fields(text)
        }
        
// Trie树实现(用于搜索提示)
type Trie struct {
    root *TrieNode
}

type TrieNode struct {
    children map[rune]*TrieNode
    isEnd    bool
    word     string
    freq     int // 搜索频率
}

func NewTrie() *Trie {
    return &Trie{
        root: &TrieNode{
            children: make(map[rune]*TrieNode),
        },
    }
}

// Insert 插入词
func (t *Trie) Insert(word string, freq int) {
    node := t.root
    
    for _, char := range word {
        if _, exists := node.children[char]; !exists {
            node.children[char] = &TrieNode{
                children: make(map[rune]*TrieNode),
            }
        }
        node = node.children[char]
    }
    
    node.isEnd = true
    node.word = word
    node.freq = freq
}

// SearchPrefix 搜索前缀
func (t *Trie) SearchPrefix(prefix string) []string {
    node := t.root
    
    // 找到前缀节点
    for _, char := range prefix {
        if _, exists := node.children[char]; !exists {
            return nil
        }
        node = node.children[char]
        }
        
    // DFS收集所有词
    results := make([]*TrieNode, 0)
    t.dfs(node, &results)
    
    // 按频率排序
    sort.Slice(results, func(i, j int) bool {
        return results[i].freq > results[j].freq
    })
    
    // 返回Top 10
    words := make([]string, 0)
    for i := 0; i < len(results) && i < 10; i++ {
        words = append(words, results[i].word)
    }
    
    return words
    }
    
// dfs 深度优先搜索
func (t *Trie) dfs(node *TrieNode, results *[]*TrieNode) {
    if node.isEnd {
        *results = append(*results, node)
    }
    
    for _, child := range node.children {
        t.dfs(child, results)
    }
}

4.2 Java实现

java
public class SearchEngine {
    
    private Map<String, Term> dictionary;
    private Map<String, List<Posting>> postingLists;
    private Map<Long, Document> documents;
    private int totalDocs;
    
    public SearchEngine() {
        this.dictionary = new HashMap<>();
        this.postingLists = new HashMap<>();
        this.documents = new HashMap<>();
        this.totalDocs = 0;
    }
    
    /**
     * 构建索引
     */
    public void buildIndex(List<Document> docs) {
        for (Document doc : docs) {
            addDocument(doc);
            }
        }
        
    /**
     * 添加文档
     */
    private void addDocument(Document doc) {
        // 分词
        List<String> terms = tokenize(doc.getContent());
        doc.setDocLength(terms.size());
        
        // 存储文档
        documents.put(doc.getId(), doc);
        totalDocs++;
        
        // 统计词频和位置
        Map<String, Integer> termFreqs = new HashMap<>();
        Map<String, List<Integer>> termPositions = new HashMap<>();
        
        for (int i = 0; i < terms.size(); i++) {
            String term = terms.get(i);
            termFreqs.put(term, termFreqs.getOrDefault(term, 0) + 1);
            termPositions.computeIfAbsent(term, k -> new ArrayList<>()).add(i);
        }
        
        // 构建倒排索引
        for (Map.Entry<String, Integer> entry : termFreqs.entrySet()) {
            String term = entry.getKey();
            int freq = entry.getValue();
            
            // 更新词典
            dictionary.computeIfAbsent(term, k -> new Term(term, 0));
            Term termInfo = dictionary.get(term);
            termInfo.setDocFreq(termInfo.getDocFreq() + 1);
            
            // 添加到倒排列表
            Posting posting = new Posting();
            posting.setDocId(doc.getId());
            posting.setTermFreq(freq);
            posting.setPositions(termPositions.get(term));
            
            postingLists.computeIfAbsent(term, k -> new ArrayList<>()).add(posting);
        }
    }
    
    /**
     * 搜索
     */
    public List<SearchResult> search(String query, int limit) {
        // 1. 查询分词
        List<String> queryTerms = tokenize(query);
        
        // 2. 获取候选文档
        Set<Long> candidateDocs = getCandidateDocs(queryTerms);
        
        // 3. 计算BM25得分
        List<SearchResult> results = new ArrayList<>();
        for (Long docId : candidateDocs) {
            double score = calculateBM25(queryTerms, docId);
            
            Document doc = documents.get(docId);
            SearchResult result = new SearchResult();
            result.setDocId(docId);
            result.setTitle(doc.getTitle());
            result.setScore(score);
            
            results.add(result);
}

        // 4. 排序
        results.sort((a, b) -> Double.compare(b.getScore(), a.getScore()));
        
        // 5. 返回Top N
        return results.stream().limit(limit).collect(Collectors.toList());
    }
    
    /**
     * 获取候选文档
     */
    private Set<Long> getCandidateDocs(List<String> queryTerms) {
        Set<Long> candidates = new HashSet<>();
        
        for (String term : queryTerms) {
            List<Posting> postings = postingLists.get(term);
            if (postings != null) {
                for (Posting posting : postings) {
                    candidates.add(posting.getDocId());
                }
            }
        }
        
        return candidates;
    }
    
    /**
     * 计算BM25得分
     */
    private double calculateBM25(List<String> queryTerms, Long docId) {
        final double k1 = 1.5;
        final double b = 0.75;
        
        Document doc = documents.get(docId);
        double avgDocLength = calculateAvgDocLength();
        double score = 0.0;
        
        for (String term : queryTerms) {
            double idf = calculateIDF(term);
            double tf = getTermFreq(term, docId);
            
            double numerator = tf * (k1 + 1);
            double denominator = tf + k1 * (1 - b + b * doc.getDocLength() / avgDocLength);
            
            score += idf * (numerator / denominator);
    }
    
        return score;
    }
    
    /**
     * 计算IDF
     */
    private double calculateIDF(String term) {
        Term termInfo = dictionary.get(term);
        if (termInfo == null) {
            return 0.0;
        }
        
        double n = totalDocs;
        double df = termInfo.getDocFreq();
        
        return Math.log((n - df + 0.5) / (df + 0.5) + 1);
}

    /**
     * 分词
     */
    private List<String> tokenize(String text) {
        return Arrays.asList(text.toLowerCase().split("\\s+"));
    }
}

五、Elasticsearch应用

5.1 Elasticsearch简介

核心特点

  • 分布式搜索引擎
  • 基于Lucene
  • RESTful API
  • 实时搜索

核心概念

  • Index(索引):类似数据库
  • Document(文档):类似行记录
  • Field(字段):类似列
  • Shard(分片):索引分片存储
  • Replica(副本):分片副本

5.2 使用示例

创建索引

json
PUT /products
{
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "ik_max_word"
      },
      "price": {
        "type": "double"
      },
      "category": {
        "type": "keyword"
      }
    }
  }
}

插入文档

json
POST /products/_doc/1
{
  "title": "iPhone 15 Pro",
  "price": 7999,
  "category": "手机"
}

搜索

json
GET /products/_search
{
  "query": {
    "match": {
      "title": "iPhone"
    }
  },
  "sort": [
    { "_score": "desc" },
    { "price": "asc" }
  ],
  "from": 0,
  "size": 10
}

六、性能优化

6.1 索引优化

分片策略

索引大小 < 50GB:1个分片
索引大小 100GB:2-5个分片
索引大小 > 500GB:10+个分片

索引压缩

  • 词典压缩:前缀树
  • 倒排列表压缩:差分编码

6.2 查询优化

缓存

  • Query Cache:查询结果缓存
  • Filter Cache:过滤条件缓存
  • Field Data Cache:字段数据缓存

性能数据

指标优化前优化后提升
索引构建速度1000doc/s10000doc/s10x
搜索响应时间500ms50ms10x
索引大小10GB5GB50%

七、面试要点

7.1 常见追问

Q1: 倒排索引和正排索引的区别?

A:

维度正排索引倒排索引
结构文档→词词→文档
用途展示文档搜索文档
查询慢(全表扫描)快(直接定位)

Q2: TF-IDF和BM25的区别?

A: BM25是TF-IDF的改进版,解决了词频饱和问题和文档长度归一化。

Q3: 如何实现中文分词?

A: 常用算法:

  • 基于词典:最大匹配法
  • 基于统计:HMM、CRF
  • 开源工具:jieba、IK Analyzer

7.2 扩展知识点

相关场景题

相关技术文档

八、总结

搜索引擎设计要点:

  1. 核心数据结构:倒排索引
  2. 相关性算法:TF-IDF、BM25
  3. 分词技术:中文分词
  4. Elasticsearch:生产级搜索引擎
  5. 性能优化:索引分片、缓存策略

面试重点

  • 倒排索引原理和实现
  • BM25算法公式
  • Elasticsearch架构和使用

参考资料

  • 《搜索引擎原理》
  • Elasticsearch官方文档
  • Lucene核心技术

正在精进