第7章:搜索引擎设计
面试频率: D|/avgdl))
其中:
- D: 文档
- Q: 查询
- qi: 查询中的第i个词
- f(qi,D): qi在D中出现的频率
- |D|: 文档D的长度
- avgdl: 平均文档长度
- k1, b: 调节参数(默认k1=1.2, b=0.75)
优点: 词频饱和(避免高频词主导) 文档长度归一化
### Go实现BM25
```go
package search
import (
"math"
)
type BM25 struct {
k1 float64 // 调节参数(默认1.2)
b float64 // 调节参数(默认0.75)
avgDocLen float64 // 平均文档长度
totalDocs int // 文档总数
}
func NewBM25(avgDocLen float64, totalDocs int) *BM25 {
return &BM25{
k1: 1.2,
b: 0.75,
avgDocLen: avgDocLen,
totalDocs: totalDocs,
}
}
// CalculateIDF 计算IDF
func (bm *BM25) CalculateIDF(docsWithTerm int) float64 {
return math.Log(float64(bm.totalDocs-docsWithTerm+0.5) / float64(docsWithTerm+0.5))
}
// CalculateScore 计算BM25分数
func (bm *BM25) CalculateScore(termFreq int, docLen int, docsWithTerm int) float64 {
// IDF部分
idf := bm.CalculateIDF(docsWithTerm)
// 词频部分
tf := float64(termFreq)
// 文档长度归一化
norm := 1 - bm.b + bm.b*(float64(docLen)/bm.avgDocLen)
// BM25公式
score := idf * (tf * (bm.k1 + 1)) / (tf + bm.k1*norm)
return score
}
// CalculateQueryScore 计算查询得分
func (bm *BM25) CalculateQueryScore(query []string, doc *Document) float64 {
score := 0.0
for _, term := range query {
termFreq := doc.GetTermFrequency(term)
docsWithTerm := GetDocsWithTerm(term)
score += bm.CalculateScore(termFreq, doc.Length, docsWithTerm)
}
return score
}
搜索建议
Trie树(前缀树)
type TrieNode struct {
children map[rune]*TrieNode
isEnd bool
freq int // 词频(用于排序)
}
type Trie struct {
root *TrieNode
}
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 _, ch := range word {
if node.children[ch] == nil {
node.children[ch] = &TrieNode{
children: make(map[rune]*TrieNode),
}
}
node = node.children[ch]
}
node.isEnd = true
node.freq = freq
}
// Search 搜索建议(自动补全)
func (t *Trie) Search(prefix string, limit int) []string {
node := t.root
// 找到前缀节点
for _, ch := range prefix {
if node.children[ch] == nil {
return []string{}
}
node = node.children[ch]
}
// DFS收集所有后缀
results := make([]string, 0)
t.dfs(node, prefix, &results, limit)
return results
}
func (t *Trie) dfs(node *TrieNode, current string, results *[]string, limit int) {
if len(*results) >= limit {
return
}
if node.isEnd {
*results = append(*results, current)
}
for ch, child := range node.children {
t.dfs(child, current+string(ch), results, limit)
}
}
Elasticsearch Completion Suggester
// Mapping定义
{
"mappings": {
"properties": {
"suggest": {
"type": "completion"
}
}
}
}
// 索引文档
POST /products/_doc/1
{
"title": "iPhone 15 Pro",
"suggest": {
"input": ["iPhone", "iPhone 15", "iPhone Pro"],
"weight": 100
}
}
// 搜索建议
POST /products/_search
{
"suggest": {
"my-suggest": {
"prefix": "iphone",
"completion": {
"field": "suggest",
"size": 10
}
}
}
}
架构设计
V1:单机全文检索
┌─────────────┐
│ Application │
└──────┬──────┘
│
↓
┌──────────────┐
│ Lucene │ (倒排索引)
│ (单机) │
└──────┬───────┘
│
↓
┌──────────────┐
│ File System │
└──────────────┘
优点:
简单
缺点:
✗ 容量受限
✗ 单点故障
✗ 性能瓶颈
V2:Elasticsearch集群
┌────────────────────────────────────────────┐
│ Load Balancer │
│ (Nginx/HAProxy) │
└───────────────────┬────────────────────────┘
│
┌───────────┼───────────┐
│ │ │
↓ ↓ ↓
┌─────────────┬─────────────┬─────────────┐
│ Coordinating│ Coordinating│ Coordinating│
│ Node 1 │ Node 2 │ Node 3 │
└──────┬──────┴──────┬───────┴──────┬──────┘
│ │ │
┌───┴─────────────┴──────────────┴───┐
│ │
↓ ↓
┌──────────────────────┐ ┌──────────────────────┐
│ Data Nodes (10) │ │ Master Nodes (3) │
│ - Shard 0 Primary │ │ - Cluster State │
│ - Shard 1 Replica │ │ - Node Management │
│ - ... │ └──────────────────────┘
└──────────────────────┘
优点:
水平扩展
高可用
自动分片
V3:多级架构(推荐)
┌─────────────┐
│ 用户请求 │
└──────┬──────┘
│
↓
┌──────────────┐
│ CDN缓存 │ (静态资源)
└──────┬───────┘
│
↓
┌──────────────┐
│ Application │
│ Gateway │ (限流、鉴权)
└──────┬───────┘
│
↓
┌──────────────┐
│ Redis缓存 │ (热点查询)
│ (30min) │
└──────┬───────┘
│ Miss
↓
┌──────────────┐
│ Elasticsearch│
│ Cluster │
└──────┬───────┘
│
↓
┌──────────────┐
│ MySQL │ (商品基础数据)
└──────────────┘
查询流程:
1. 检查Redis缓存
2. 未命中 → ES查询
3. ES查询结果写入Redis
4. 异步更新ES索引(从MySQL)
核心实现
简化版搜索引擎
package search
import (
"sort"
"strings"
"sync"
)
// SearchEngine 搜索引擎
type SearchEngine struct {
index map[string]*PostingList // 倒排索引
documents map[int]*Document // 文档存储
mu sync.RWMutex
tokenizer *ChineseTokenizer
bm25 *BM25
}
// Document 文档
type Document struct {
ID int
Title string
Content string
Length int
Terms map[string]int // term → frequency
}
// PostingList 倒排列表
type PostingList struct {
Term string
Postings []*Posting
}
// Posting 倒排项
type Posting struct {
DocID int
Frequency int
Positions []int
}
func NewSearchEngine() *SearchEngine {
return &SearchEngine{
index: make(map[string]*PostingList),
documents: make(map[int]*Document),
tokenizer: NewChineseTokenizer(),
bm25: NewBM25(100, 0),
}
}
// IndexDocument 索引文档
func (se *SearchEngine) IndexDocument(doc *Document) {
se.mu.Lock()
defer se.mu.Unlock()
// 分词
terms := se.tokenizer.Tokenize(doc.Title + " " + doc.Content)
// 统计词频和位置
doc.Terms = make(map[string]int)
termPositions := make(map[string][]int)
for pos, term := range terms {
doc.Terms[term]++
termPositions[term] = append(termPositions[term], pos)
}
doc.Length = len(terms)
// 存储文档
se.documents[doc.ID] = doc
// 构建倒排索引
for term, freq := range doc.Terms {
if se.index[term] == nil {
se.index[term] = &PostingList{
Term: term,
Postings: make([]*Posting, 0),
}
}
se.index[term].Postings = append(se.index[term].Postings, &Posting{
DocID: doc.ID,
Frequency: freq,
Positions: termPositions[term],
})
}
// 更新BM25参数
se.updateBM25Stats()
}
// Search 搜索
func (se *SearchEngine) Search(query string, limit int) []*SearchResult {
se.mu.RLock()
defer se.mu.RUnlock()
// 分词
terms := se.tokenizer.Tokenize(query)
// 找到包含所有词的文档(AND查询)
candidates := se.findCandidates(terms)
// 计算BM25分数
results := make([]*SearchResult, 0)
for docID := range candidates {
doc := se.documents[docID]
score := se.calculateScore(terms, doc)
results = append(results, &SearchResult{
DocID: docID,
Title: doc.Title,
Score: score,
})
}
// 按分数排序
sort.Slice(results, func(i, j int) bool {
return results[i].Score > results[j].Score
})
// 返回Top N
if len(results) > limit {
results = results[:limit]
}
return results
}
// findCandidates 找候选文档
func (se *SearchEngine) findCandidates(terms []string) map[int]bool {
candidates := make(map[int]bool)
for i, term := range terms {
postingList := se.index[term]
if postingList == nil {
// 有词未找到,返回空
return make(map[int]bool)
}
if i == 0 {
// 第一个词,加入所有文档
for _, posting := range postingList.Postings {
candidates[posting.DocID] = true
}
} else {
// 后续词,取交集
newCandidates := make(map[int]bool)
for _, posting := range postingList.Postings {
if candidates[posting.DocID] {
newCandidates[posting.DocID] = true
}
}
candidates = newCandidates
}
}
return candidates
}
// calculateScore 计算BM25分数
func (se *SearchEngine) calculateScore(terms []string, doc *Document) float64 {
score := 0.0
for _, term := range terms {
termFreq := doc.Terms[term]
docsWithTerm := len(se.index[term].Postings)
score += se.bm25.CalculateScore(termFreq, doc.Length, docsWithTerm)
}
return score
}
// updateBM25Stats 更新BM25统计信息
func (se *SearchEngine) updateBM25Stats() {
totalLen := 0
for _, doc := range se.documents {
totalLen += doc.Length
}
avgDocLen := float64(totalLen) / float64(len(se.documents))
se.bm25 = NewBM25(avgDocLen, len(se.documents))
}
// SearchResult 搜索结果
type SearchResult struct {
DocID int
Title string
Score float64
}
性能优化
1. 索引优化
{
"settings": {
// 写入优化
"index.refresh_interval": "30s", // 默认1s,增加可提升写入
"index.number_of_replicas": 0, // 写入时关闭副本
// 查询优化
"index.max_result_window": 10000,
// 缓存
"index.queries.cache.enabled": true
}
}
2. 查询优化
// 使用Filter(可缓存)代替Query
GET /products/_search
{
"query": {
"bool": {
"must": [
{ "match": { "title": "iPhone" } }
],
"filter": [ // Filter会被缓存
{ "range": { "price": { "gte": 5000, "lte": 10000 } } },
{ "term": { "category": "Electronics" } }
]
}
}
}
// 分页优化:使用search_after代替from/size
GET /products/_search
{
"size": 10,
"query": { "match_all": {} },
"search_after": [12345], // 上一页最后一个文档的sort值
"sort": [
{ "_id": "asc" }
]
}
3. 分片优化
分片数量选择:
- 单个分片大小:20-40GB
- 分片数 = 总数据量 / 单个分片大小
示例:
总数据:500GB
单分片:25GB
分片数:500 / 25 = 20个
副本数量:
- 生产环境:至少1个副本
- 高可用:2个副本
监控告警
Elasticsearch监控指标
# Prometheus Exporter
elasticsearch_exporter:
metrics:
# 集群健康
- elasticsearch_cluster_health_status
- elasticsearch_cluster_health_number_of_nodes
- elasticsearch_cluster_health_active_shards
# 索引性能
- elasticsearch_indices_indexing_index_total
- elasticsearch_indices_search_query_total
- elasticsearch_indices_search_query_time_seconds
# JVM内存
- elasticsearch_jvm_memory_used_bytes
- elasticsearch_jvm_memory_max_bytes
- elasticsearch_jvm_gc_collection_seconds_sum
# 节点性能
- elasticsearch_process_cpu_percent
- elasticsearch_fs_total_available_bytes
告警规则
groups:
- name: elasticsearch_alerts
rules:
# 集群状态异常
- alert: ClusterHealthRed
expr: elasticsearch_cluster_health_status{color="red"} == 1
for: 5m
labels:
severity: critical
annotations:
summary: "ES集群状态RED"
# 查询延迟高
- alert: HighQueryLatency
expr: |
rate(elasticsearch_indices_search_query_time_seconds[5m]) /
rate(elasticsearch_indices_search_query_total[5m]) > 1
for: 10m
labels:
severity: warning
annotations:
summary: "查询平均延迟超过1秒"
# 磁盘使用率高
- alert: HighDiskUsage
expr: |
(elasticsearch_fs_total_total_bytes - elasticsearch_fs_total_available_bytes) /
elasticsearch_fs_total_total_bytes > 0.85
for: 5m
labels:
severity: warning
annotations:
summary: "磁盘使用率超过85%"
面试问答
倒排索引和正排索引有什么区别?
正排索引:文档ID → 内容(适合根据ID查文档) 倒排索引:词 → 文档列表(适合全文检索)
正排索引:
Doc1 → "iPhone 15 Pro"
Doc2 → "Samsung Galaxy"
倒排索引:
"iPhone" → [Doc1]
"15" → [Doc1]
"Pro" → [Doc1]
"Samsung" → [Doc2]
"Galaxy" → [Doc2]
Elasticsearch如何实现分布式搜索?
分片 + 路由:
1. 写入:
hash(document_id) % num_primary_shards → Shard ID
写入Primary Shard,同步到Replica
2. 搜索:
Coordinating Node收到请求
→ 广播到所有Shard(Primary或Replica)
→ 并行查询
→ 聚合结果,排序
→ 返回Top N
如何优化Elasticsearch查询性能?
答案:
- 使用Filter代替Query(Filter可缓存)
- 合理设置分片数(20-40GB/分片)
- 增加副本(提升读性能)
- 使用search_after代替from/size(深分页)
- Mapping优化(不需要分词的用keyword)
- 禁用_source(只返回需要的字段)
{
"query": {
"bool": {
"must": [
{ "match": { "title": "iPhone" } }
],
"filter": [ // 优先使用filter
{ "range": { "price": { "gte": 5000 } } }
]
}
},
"_source": ["title", "price"] // 只返回需要的字段
}
什么是BM25算法?
BM25(Best Matching 25) 是改进的TF-IDF:
改进点:
- 词频饱和:避免高频词主导分数
- 文档长度归一化:短文档和长文档公平比较
BM25 = IDF × (TF × (k1+1)) / (TF + k1 × (1-b+b×docLen/avgDocLen))
k1: 调节TF饱和度(默认1.2)
b: 调节文档长度影响(默认0.75)
如何实现搜索建议(自动补全)?
方案1:Trie树(前缀树)
输入:"iph"
Trie查找:
i → p → h → one (iPhone)
→ ad (iPad)
返回:["iPhone", "iPad"]
方案2:ES Completion Suggester
{
"suggest": {
"type": "completion",
"input": ["iPhone", "iPhone 15"],
"weight": 100
}
}
如何处理海量数据的索引更新?
答案:
- 批量索引(Bulk API)
POST /_bulk
{ "index": { "_index": "products", "_id": "1" } }
{ "title": "iPhone 15" }
{ "index": { "_index": "products", "_id": "2" } }
{ "title": "iPad Pro" }
- 调整refresh_interval
{
"settings": {
"index.refresh_interval": "30s" // 默认1s
}
}
- 关闭副本(导入时)
{
"settings": {
"index.number_of_replicas": 0
}
}
// 导入完成后再打开副本
- 增量更新(使用update API)
Elasticsearch的写入流程是什么?
1. 客户端发送写入请求
↓
2. Coordinating Node接收请求
↓
3. 路由到Primary Shard(hash(doc_id) % num_shards)
↓
4. Primary Shard写入:
- 写入Memory Buffer
- 写入Translog(持久化)
↓
5. 并行复制到Replica Shards
↓
6. 所有Replica确认后,返回成功
↓
7. Refresh(默认1秒):
- Memory Buffer → Segment(可搜索)
↓
8. Flush(默认30分钟):
- Segment → 磁盘
- 清空Translog
如何实现拼写纠错?
方案1:编辑距离(Levenshtein Distance)
func LevenshteinDistance(s1, s2 string) int {
// 计算两个字符串的最小编辑距离
// "iphone" vs "iphoen" = 1
}
// 纠错:找编辑距离<=2的词
方案2:ES Suggester
POST /products/_search
{
"suggest": {
"my-suggest": {
"text": "ipone",
"term": {
"field": "title",
"suggest_mode": "popular", // popular/missing/always
"max_edits": 2
}
}
}
}
// 返回:"iphone"
Elasticsearch和Solr有什么区别?
| 特性 | Elasticsearch | Solr |
|---|---|---|
| 架构 | 原生分布式 | 需要ZooKeeper |
| 性能 | 写入快,实时性好 | 查询快(复杂查询) |
| 易用性 | REST API,简单 | 配置复杂 |
| 生态 | ELK Stack完善 | 依赖Hadoop生态 |
| 社区 | 活跃,发展快 | 成熟,更新慢 |
选择建议:
- 实时搜索、日志分析 → Elasticsearch
- 传统搜索、复杂查询 → Solr
如何设计一个电商搜索系统?
答案:
架构:
用户 → CDN → API Gateway → Redis缓存 → Elasticsearch → MySQL
核心功能:
- 全文检索:商品标题、描述
- 过滤:价格区间、品牌、分类
- 排序:相关性、销量、价格
- 搜索建议:输入补全
- 拼写纠错:自动纠正错误
Mapping设计:
{
"mappings": {
"properties": {
"title": { "type": "text", "analyzer": "ik_max_word" },
"price": { "type": "double" },
"sales": { "type": "long" },
"category": { "type": "keyword" },
"brand": { "type": "keyword" },
"suggest": { "type": "completion" }
}
}
}
查询示例:
{
"query": {
"bool": {
"must": [
{ "match": { "title": "iPhone" } }
],
"filter": [
{ "range": { "price": { "gte": 5000, "lte": 10000 } } },
{ "term": { "brand": "Apple" } }
]
}
},
"sort": [
{ "_score": "desc" },
{ "sales": "desc" }
]
}