Building a semantic search API in Go with Meilisearch
Building a semantic search API in Go with Meilisearch
使用 Go 和 Meilisearch 构建语义搜索 API
Full-text search is one of those features that looks simple until you have to ship it. Typos fail silently. Category filters conflict with relevance ranking. The database LIKE query that worked at 10,000 rows grinds to a halt at 100,000. This tutorial walks through building a real search API in Go using Fiber and Meilisearch, complete with filter support, typo tolerance configuration, and a MySQL LIKE fallback for resilience. This is roughly the architecture running search across 1,600+ cybersecurity articles at AYI NEDJIMI Consultants.
全文搜索是那种看起来很简单,但实际交付时却充满挑战的功能。拼写错误会导致搜索静默失败;分类过滤器会与相关性排序产生冲突;在 1 万行数据时表现良好的数据库 LIKE 查询,在 10 万行时就会陷入停滞。本教程将引导你使用 Go 语言的 Fiber 框架和 Meilisearch 构建一个真正的搜索 API,涵盖过滤器支持、拼写容错配置以及用于增强鲁棒性的 MySQL LIKE 回退机制。这正是 AYI NEDJIMI Consultants 目前用于支撑 1600 多篇网络安全文章搜索功能的架构。
Setup
环境设置
go mod init search-api
go get github.com/gofiber/fiber/v2
go get github.com/meilisearch/meilisearch-go
go get github.com/go-sql-driver/mysql
Run Meilisearch: 运行 Meilisearch:
docker run -d -p 7700:7700 \
-e MEILI_MASTER_KEY=your_master_key \
getmeili/meilisearch:latest
Project structure
项目结构
search-api/
├── main.go
├── config/
│ └── config.go
├── search/
│ ├── meili.go
│ └── fallback.go
└── handlers/
└── search.go
Data model
数据模型
// search/meili.go
package search
// Article is the document type stored in Meilisearch and MySQL.
// Article 是存储在 Meilisearch 和 MySQL 中的文档类型。
type Article struct {
ID string `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
Content string `json:"content"` // plain text, stripped of HTML (纯文本,已去除 HTML)
Category string `json:"category"` // news, guide, analyse, blog, checklist
Difficulty string `json:"difficulty"` // beginner, intermediate, advanced
DocType string `json:"doc_type"` // article, checklist, glossary
Tags []string `json:"tags"`
PublishedAt int64 `json:"published_at"` // Unix timestamp for sort (用于排序的 Unix 时间戳)
}
// SearchResult wraps hits with metadata.
// SearchResult 封装了搜索结果及其元数据。
type SearchResult struct {
Hits []Article `json:"hits"`
TotalHits int64 `json:"total_hits"`
ProcessingTimeMs int64 `json:"processing_time_ms"`
Query string `json:"query"`
Source string `json:"source"` // "meilisearch" or "mysql_fallback"
}
Meilisearch client initialization
Meilisearch 客户端初始化
// search/meili.go (continued)
package search
import (
"fmt"
"log"
"github.com/meilisearch/meilisearch-go"
)
const IndexName = "articles"
type MeiliSearcher struct {
client meilisearch.ServiceManager
index meilisearch.IndexManager
}
func NewMeiliSearcher(host, apiKey string) (*MeiliSearcher, error) {
client := meilisearch.New(host, meilisearch.WithAPIKey(apiKey))
// Verify connectivity (验证连接)
if _, err := client.Health(); err != nil {
return nil, fmt.Errorf("meilisearch unreachable at %s: %w", host, err)
}
s := &MeiliSearcher{client: client}
if err := s.ensureIndex(); err != nil {
return nil, err
}
return s, nil
}
Index configuration
索引配置
func (s *MeiliSearcher) configureIndex() error {
task, err := s.index.UpdateSettings(&meilisearch.Settings{
SearchableAttributes: []string{
"title", // highest weight (最高权重)
"tags",
"content", // lowest weight (最低权重)
},
FilterableAttributes: []string{
"category", "difficulty", "doc_type",
},
SortableAttributes: []string{
"published_at",
},
RankingRules: []string{
"words", "typo", "proximity", "attribute",
"sort", "exactness",
},
TypoTolerance: &meilisearch.TypoTolerance{
Enabled: func() *bool { b := true; return &b }(),
MinWordSizeForTypos: meilisearch.MinWordSizeForTypos{
OneTypo: 4, TwoTypos: 8,
},
},
})
// ... (省略后续错误处理)
}
Index sync function
索引同步函数
Call this on startup and hook it to your CRUD operations. 在启动时调用此函数,并将其挂载到你的 CRUD 操作中。
func (s *MeiliSearcher) IndexDocuments(articles []Article) error {
if len(articles) == 0 { return nil }
task, err := s.index.AddDocuments(articles, "id")
if err != nil { return fmt.Errorf("add documents: %w", err) }
s.client.WaitForTask(task.TaskUID, nil)
return nil
}
Search with filters
带过滤器的搜索
func (s *MeiliSearcher) Search(params SearchParams) (*SearchResult, error) {
// ... (参数校验与过滤器构建逻辑)
req := &meilisearch.SearchRequest{
Limit: params.Limit,
Offset: params.Offset,
AttributesToRetrieve: []string{
"id", "title", "slug", "category", "difficulty", "doc_type", "tags", "published_at",
},
}
// 执行搜索并返回结果
// ...
}