📖 开场:图书馆找书
想象你在图书馆找书 📚:
传统方法(遍历):
找《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: 五种优化:
-
增加分片数:
- 并发查询
- 分散压力
-
增加副本数:
- 分担查询压力
- 提高可用性
-
缓存热点查询:
- Redis缓存搜索结果
- 减少ES压力
-
使用过滤(Filter)而不是查询:
- Filter可以缓存
- 性能更好
-
限制返回字段:
sourceBuilder.fetchSource(new String[]{"title", "author"}, null);
Q6: ElasticSearch vs Solr?
A: 对比:
| 特性 | ElasticSearch | Solr |
|---|---|---|
| 易用性 | ⭐⭐⭐ 简单 | ⭐⭐ 中等 |
| 实时性 | ⭐⭐⭐ 近实时 | ⭐⭐ 延迟高 |
| 分布式 | ⭐⭐⭐ 天生分布式 | ⭐⭐ 需要ZooKeeper |
| 社区 | ⭐⭐⭐ 活跃 | ⭐⭐ 较活跃 |
| 性能 | ⭐⭐⭐ 高 | ⭐⭐⭐ 高 |
推荐:ElasticSearch ⭐⭐⭐
🎬 总结
搜索引擎核心技术
┌────────────────────────────────────┐
│ 倒排索引 │
│ - 关键词 → 文档列表 │
│ - 快速定位 │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ 分词 │
│ - IK分词器(中文) │
│ - Standard分词器(英文) │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ 相关性评分 │
│ - TF-IDF │
│ - BM25(ElasticSearch默认) │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ ElasticSearch │
│ - 分布式 │
│ - 近实时 │
│ - 高性能 │
└────────────────────────────────────┘
ElasticSearch是最佳选择!✅
🎉 恭喜你!
你已经完全掌握了搜索引擎系统的设计!🎊
核心要点:
- 倒排索引:关键词→文档列表
- 分词:IK分词器(中文)
- 相关性评分:TF-IDF、BM25
- ElasticSearch:分布式、高性能
下次面试,这样回答:
"搜索引擎的核心是倒排索引,将关键词映射到文档列表,实现快速检索。
我们使用ElasticSearch实现,配置IK分词器进行中文分词。创建索引时,设置3个主分片和2个副本,标题字段权重设为3倍,内容字段权重1倍。
搜索时使用MultiMatchQuery在标题和内容中查询,按BM25算法计算相关性评分,并按评分和浏览量排序。结果支持高亮显示,将匹配的关键词用红色标注。
性能优化方面,通过增加分片数支持并发查询,使用Redis缓存热点查询结果,限制返回字段减少网络传输。
我们项目的文章搜索系统采用ElasticSearch,支持千万级文档,平均响应时间50ms,搜索准确率95%以上。"
面试官:👍 "很好!你对搜索引擎系统的设计理解很深刻!"
本文完 🎬
上一篇: 202-设计一个文件上传和存储服务.md
下一篇: 204-设计一个新闻Feed流系统.md
作者注:写完这篇,我都想去Google做搜索引擎了!🔍
如果这篇文章对你有帮助,请给我一个Star⭐!