Golang

关注公众号 jb51net

关闭
首页 > 脚本专栏 > Golang > Go搜索引擎Elasticsearch与Meilisearch

Go搜索引擎Elasticsearch与Meilisearch的实现

作者:白话机器学习

本文主要介绍了Go搜索引擎Elasticsearch与Meilisearch的实现,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧

本篇讲解Go语言集成Elasticsearch和Meilisearch两个搜索引擎,用olivere/elastic实现索引CRUD和全文搜索加聚合分析,用Meilisearch的轻量HTTP API实现即时搜索,分享ES mapping字段类型设置错误导致查询无结果的踩坑经验,对比Elasticsearch、Meilisearch和数据库LIKE三种搜索方案。

开篇故事

我们的商品搜索原先是MySQL的LIKE查询,商品表30万条数据。用户搜"无线耳机",LIKE '%无线%'扫全表,平均耗时1.2秒,高峰期2秒以上。产品要求改成毫秒级返回,还要支持按价格区间过滤、按销量排序、按品牌聚合。

先上了Elasticsearch,把30万商品数据同步过去。全文搜索+过滤+排序组合查询,平均返回时间降到30毫秒。但ES的mapping配置有坑,踩过一次查询死活不出结果,排查半天发现是字段类型设错了。

后来有个内部小项目只需要简单全文搜索,数据量不到5万条,上ES太重了。发现Meilisearch,单二进制部署,开箱即用,索引5万条数据搜索延迟在10毫秒以内。这篇把两个搜索引擎的Go集成写清楚。

一、Elasticsearch索引与搜索

olivere/elastic是Go操作ES最成熟的客户端库。核心概念是索引(类似数据库表)、文档(类似行记录)、mapping(类似表结构)。先建索引设mapping,再写文档,最后搜索。

先看索引创建和文档写入。mapping定义字段类型,text类型会分词建立倒排索引,keyword类型不分词用于精确匹配和聚合。

package search

import (
	"context"
	"encoding/json"
	"errors"
	"log"
	"strconv"
	"time"

	"github.com/olivere/elastic/v7"
)

// Product 商品结构体
type Product struct {
	ID        int64   `json:"id"`         // 商品ID
	Name      string  `json:"name"`       // 商品名称,全文搜索
	Brand     string  `json:"brand"`      // 品牌,精确匹配
	Category  string  `json:"category"`   // 分类,聚合
	Price     float64 `json:"price"`      // 价格,范围过滤
	Sales     int     `json:"sales"`       // 销量,排序
	CreatedAt string  `json:"created_at"` // 创建时间
}

// ESClient Elasticsearch客户端封装
type ESClient struct {
	client *elastic.Client // olivere/elastic客户端
}

// NewESClient 创建ES客户端
// url: ES地址,如"http://127.0.0.1:9200"
func NewESClient(url string) (*ESClient, error) {
	// 创建客户端,设置超时和重试
	client, err := elastic.NewClient(
		elastic.SetURL(url),
		// 请求超时10秒
		elastic.SetTimeout(10*time.Second),
		// 健康检查
		elastic.SetSniff(false),
		// 错误日志
		elastic.SetErrorLog(log.Default()),
	)
	if err != nil {
		return nil, err
	}
	return &ESClient{client: client}, nil
}

// CreateIndex 创建商品索引
// indexName: 索引名,如"products"
func (c *ESClient) CreateIndex(ctx context.Context, indexName string) error {
	// 先检查索引是否已存在
	exists, err := c.client.IndexExists(indexName).Do(ctx)
	if err != nil {
		return err
	}
	if exists {
		return errors.New("索引已存在: " + indexName)
	}

	// 定义mapping,指定每个字段的类型
	mapping := `{
		"mappings": {
			"properties": {
				"id":        { "type": "long" },
				"name":      { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" },
				"brand":     { "type": "keyword" },
				"category":  { "type": "keyword" },
				"price":     { "type": "double" },
				"sales":     { "type": "integer" },
				"created_at":{ "type": "date" }
			}
		}
	}`

	// 创建索引并应用mapping
	_, err = c.client.CreateIndex(indexName).Body(mapping).Do(ctx)
	return err
}

// IndexDoc 写入单个文档
// indexName: 索引名
// doc: 商品文档
func (c *ESClient) IndexDoc(ctx context.Context, indexName string, doc *Product) error {
	// 用商品ID作为文档ID,保证幂等
	_, err := c.client.Index().
		Index(indexName).
		Id(strconv.FormatInt(doc.ID, 10)).
		BodyJson(doc).
		Do(ctx)
	return err
}

// BulkIndex 批量写入文档
// 批量写入比逐条写入快很多,减少网络往返
func (c *ESClient) BulkIndex(ctx context.Context, indexName string, docs []*Product) error {
	// 创建批量处理器
	bulk := c.client.Bulk()
	for _, doc := range docs {
		// 每个文档一个Index请求
		req := elastic.NewBulkIndexRequest().
			Index(indexName).
			Doc(doc)
		bulk = bulk.Add(req)
	}
	// 一次性提交
	_, err := bulk.Do(ctx)
	return err
}

搜索是ES的核心能力。bool查询组合must(必须匹配)、filter(过滤不评分)、should(可选匹配)。aggregations做聚合统计。

// SearchResult 搜索结果
type SearchResult struct {
	Total int64      `json:"total"` // 总匹配数
	Items []*Product `json:"items"` // 商品列表
	Brands map[string]int64 `json:"brands"` // 品牌聚合
}

// SearchProducts 搜索商品
// keyword: 搜索关键词
// minPrice, maxPrice: 价格区间
// brand: 品牌过滤,空则不过滤
// from, size: 分页
func (c *ESClient) SearchProducts(ctx context.Context, indexName, keyword string,
	minPrice, maxPrice float64, brand string, from, size int) (*SearchResult, error) {
	// 构造bool查询
	boolQuery := elastic.NewBoolQuery()

	// 关键词全文搜索,放在must里参与评分
	if keyword != "" {
		matchQuery := elastic.NewMatchQuery("name", keyword)
		boolQuery = boolQuery.Must(matchQuery)
	}

	// 价格区间过滤,放在filter里不参与评分
	priceRange := elastic.NewRangeQuery("price").
		Gte(minPrice).
		Lte(maxPrice)
	boolQuery = boolQuery.Filter(priceRange)

	// 品牌精确匹配
	if brand != "" {
		brandTerm := elastic.NewTermQuery("brand", brand)
		boolQuery = boolQuery.Filter(brandTerm)
	}

	// 构造搜索请求
	search := c.client.Search().
		Index(indexName).
		Query(boolQuery).
		From(from). // 分页起始位置
		Size(size). // 每页数量
		// 按销量降序,销量相同按价格升序
		Sort("sales", false).
		Sort("price", true)

	// 添加品牌聚合,统计每个品牌的商品数量
	brandAgg := elastic.NewTermsAggregation().Field("brand")
	search = search.Aggregation("brands", brandAgg)

	// 执行搜索
	res, err := search.Do(ctx)
	if err != nil {
		return nil, err
	}

	// 解析结果
	result := &SearchResult{
		Total: res.TotalHits(),
		Items: make([]*Product, 0),
		Brands: make(map[string]int64),
	}

	// 遍历命中文档
	for _, hit := range res.Hits.Hits {
		var p Product
		if err := json.Unmarshal(hit.Source, &p); err != nil {
			continue
		}
		result.Items = append(result.Items, &p)
	}

	// 解析聚合结果
	if brands, found := res.Aggregations.Terms("brands"); found {
		for _, bucket := range brands.Buckets {
			result.Brands[bucket.Key.(string)] = bucket.DocCount
		}
	}

	return result, nil
}

全文搜索放must里参与相关度评分,过滤条件放filter里不评分只筛选。filter不评分性能更好,ES会对filter结果做缓存。聚合放顶层aggregations里,搜索同时返回聚合统计。

二、Meilisearch轻量替代

Meilisearch比ES轻很多,单个二进制文件部署,没有复杂的集群配置。适合数据量中小、搜索场景简单的项目。Go通过HTTP API集成,也可以用官方Go SDK。

package search

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"time"
)

// MeiliClient Meilisearch客户端
type MeiliClient struct {
	url   string // Meilisearch地址
	key   string // API密钥
	http  *http.Client // HTTP客户端
}

// NewMeiliClient 创建Meilisearch客户端
// url: 地址,如"http://127.0.0.1:7700"
// key: API密钥,无鉴权可传空
func NewMeiliClient(url, key string) *MeiliClient {
	return &MeiliClient{
		url:  url,
		key:  key,
		http: &http.Client{Timeout: 10 * time.Second},
	}
}

// AddDocuments 批量添加文档
// index: 索引名
// docs: 文档列表,自动序列化JSON
func (c *MeiliClient) AddDocuments(ctx context.Context, index string, docs interface{}) error {
	// 序列化文档列表
	body, err := json.Marshal(docs)
	if err != nil {
		return err
	}

	// 构造请求
	url := fmt.Sprintf("%s/indexes/%s/documents", c.url, index)
	req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/json")
	if c.key != "" {
		req.Header.Set("Authorization", "Bearer "+c.key)
	}

	// 发送请求
	resp, err := c.http.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	return nil
}

// Search 执行搜索
// query: 搜索关键词
// filter: 过滤表达式,如"price >= 50 AND price <= 200"
// limit: 返回数量
func (c *MeiliClient) Search(ctx context.Context, index, query, filter string, limit int) (map[string]interface{}, error) {
	// 构造搜索请求体
	searchReq := map[string]interface{}{
		"q": query,
		"limit": limit,
	}
	if filter != "" {
		searchReq["filter"] = filter
	}

	body, _ := json.Marshal(searchReq)
	url := fmt.Sprintf("%s/indexes/%s/search", c.url, index)
	req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")
	if c.key != "" {
		req.Header.Set("Authorization", "Bearer "+c.key)
	}

	resp, err := c.http.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	// 解析响应
	var result map[string]interface{}
	json.NewDecoder(resp.Body).Decode(&result)
	return result, nil
}

// UpdateSearchableAttributes 设置可搜索字段
// 默认所有字段都可搜索,指定后只搜索指定字段
func (c *MeiliClient) UpdateSearchableAttributes(ctx context.Context, index string, fields []string) error {
	body, _ := json.Marshal(map[string]interface{}{"searchableAttributes": fields})
	url := fmt.Sprintf("%s/indexes/%s/settings", c.url, index)
	req, _ := http.NewRequestWithContext(ctx, "PATCH", url, bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	if c.key != "" {
		req.Header.Set("Authorization", "Bearer "+c.key)
	}
	resp, err := c.http.Do(req)
	if err != nil {
		return err
	}
	resp.Body.Close()
	return nil
}

Meilisearch开箱支持拼写纠错和前缀匹配,搜索体验比ES默认配置好。filter语法简单,类似SQL表达式。适合不需要复杂聚合的场景。

三、踩坑经验:ES mapping类型错误导致查询无结果

这个坑排查了半天。商品索引的price字段,第一版mapping里设成了text类型。写入数据正常,文档能查到。但价格区间过滤怎么都查不出结果,price范围查询返回0条命中。

// 错误的mapping,price设成了text
badMapping := `{
	"mappings": {
		"properties": {
			"name":  { "type": "text" },
			"price": { "type": "text" }
		}
	}
}`

text类型会被分词器处理。写入价格99.9,分词后变成"99"和"9"两个token。range查询要在数字类型上操作,text类型没有数值范围,自然查不出结果。

ES的字段类型一旦索引了文档就不能改。要修mapping必须删索引重建。

// 修复步骤: 删除旧索引,用正确mapping重建
func (c *ESClient) FixMapping(ctx context.Context, indexName string) error {
	// 删除旧索引
	_, err := c.client.DeleteIndex(indexName).Do(ctx)
	if err != nil {
		// 索引不存在不算错误
		log.Printf("删除索引: %v", err)
	}

	// 用正确的mapping重建
	// price必须是double或scaled_float类型
	mapping := `{
		"mappings": {
			"properties": {
				"name":  { "type": "text", "analyzer": "ik_max_word" },
				"price": { "type": "double" },
				"brand": { "type": "keyword" }
			}
		}
	}`
	_, err = c.client.CreateIndex(indexName).Body(mapping).Do(ctx)
	return err
}

踩过这个坑后总结了几条mapping设置原则。数值字段用integer或double,需要范围过滤绝不能用text。品牌、分类等需要精确匹配和聚合的字段用keyword。只有需要全文搜索的字段用text。text和keyword的区别是text会分词,keyword不分词整体存储。一个字段既要全文搜索又要精确匹配,可以用multi-field,主字段text加一个子字段keyword。

// multi-field: name既能全文搜索又能精确匹配
multiMapping := `{
	"mappings": {
		"properties": {
			"name": {
				"type": "text",
				"analyzer": "ik_max_word",
				"fields": {
					"keyword": { "type": "keyword" }
				}
			}
		}
	}
}`

上线前先用少量数据验证mapping,确认所有查询都能正常返回结果,再全量同步。避免数据同步完了才发现mapping有问题,重建索引费时费力。

四、对比分析

特性ElasticsearchMeilisearchMySQL LIKE
搜索性能高(毫秒级)高(毫秒级)低(秒级)
部署复杂度高(集群+JVM)低(单二进制)无(随数据库)
全文搜索强(分词+评分)强(开箱即用)弱(只有LIKE)
聚合分析支持不支持GROUP BY
资源占用高(1G起步)低(百M级)
数据量上限亿级千万级百万级

数据量百万以内且只有简单搜索需求,数据库LIKE勉强能用但性能差。数据量千万以内搜索需求简单,Meilisearch部署简单搜索体验好。数据量千万以上需要复杂聚合和评分,Elasticsearch是标准选择。三者的资源占用差距很大,ES动辄几个G内存,Meilisearch几百M,选型要考虑运维成本。

总结

Elasticsearch用olivere/elastic集成,mapping里数值字段用数值类型,文本搜索用text,精确匹配用keyword,复杂查询用bool组合must和filter。Meilisearch单二进制部署,HTTP API集成简单,适合中小数据量的即时搜索。mapping类型设错会导致查询无结果,上线前用少量数据验证。选型看数据量和搜索复杂度,别对小数据量上ES,也别对大数据量指望LIKE。

到此这篇关于Go搜索引擎Elasticsearch与Meilisearch的实现的文章就介绍到这了,更多相关Go搜索引擎Elasticsearch与Meilisearch内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文