轻量搜索引擎-MeiliSearch
2023-02-14 21:47:57 阿炯

本站赞助商链接,请多关照。 MeiliSearch 是近年开源的项目,主要目标是在小数据规模下实现比 ES 更加快速和易用的搜索体验。是 Rust 实现的高性能开源搜索引擎并在MIT协议下授权,支持方便地集成到任何网站或应用程序,支持自托管 (self-hosting),可作为 Algolia 和 Elasticsearch 的轻量替代方案。


Meilisearch针对数据在 500GB 左右的搜索需求,极快,单文件,超轻量,内置了许多实用功能,比如:

快速的输入即搜索 (search-as-you-type) 体验,也称作 “即时搜索”
支持冗错/纠错搜索 (typo tolerance)
支持多面搜索 (faceted search)
支持基于地理位置的搜索 (geosearch)
支持多租户 (multi-tenancy)

此外Meilisearch 提供了一整套完整的 SDK 和库,让开发者便捷地将其连接到流行的编程语言和 Web 工具。Meilisearch 的搜索功能支持所有语言,对任何使用空格分隔单词的语言以及中文、日语、希伯来语、泰语和韩语都进行了特殊优化。

特点

开源
同义词
纠错
高亮
全文返回
高级搜索
停用词、停用字段
加权、降权
逻辑搜索
唯一字段聚合
分页
重新索引
中文分词


Meilisearch由总部位于巴黎的同名公司开发。截至 2024 年年中该项目在 GitHub 上获得了超过4万7千个星标,成为该平台上星标数最多的搜索相关项目之一。其的第一个稳定版本于 2019 年发布,此后该引擎得到了显著发展——2023 年 3 月发布的 v1.0 版本已具备正式部署的条件。它并不试图成为 Elasticsearch 的替代品,而是通过做出特定的权衡来优化某一特定使用场景——即用户在应用程序的搜索栏中输入内容时的搜索体验——并且绝不为了追求通用性而牺牲这一核心体验。这些权衡体现在四个关键的设计决策中。

Meilisearch 使用 LMDB 将所有数据存储在内存映射文件中。LMDB 是一种高性能的嵌入式键值存储系统,最初是为 OpenLDAP 项目开发的。这就是搜索速度快的原因:搜索索引存储在内存中(从逻辑上讲是如此,尽管在物理上可能并非完全如此),因此在查询过程中,热点路径上不会涉及磁盘 I/O 操作。不过,这种方式的缺点是索引大小不能远远超过可用 RAM 的容量——更多细节请参见注意事项部分。

拼写错误容忍度非常高,且是系统默认设置的。Meilisearch 使用改进后的 BK 树数据结构来在查询时计算编辑距离(莱文斯坦距离)。对于 5 到 8 个字符的单词,允许 1 个拼写错误;对于 9 个字符或更长的单词,则允许 2 个拼写错误。这一设置无法在每次查询时单独配置;这是产品层面的决策——引擎比用户更清楚什么样的拼写错误是可以接受的。以我的经验来看,这一决策 95%的情况下都是正确的。

排名机制是基于规则的,而非机器学习算法。Meilisearch 按顺序应用一系列确定的排名规则:拼写距离、地理距离(如果使用地理搜索功能)、匹配词的数量、精确匹配的质量、单词的接近程度、属性排名。如果用户自定义了排名规则,该规则也会被应用。该系统不使用 BM25 或 TF-IDF 作为主要排序依据,也没有专门的排序学习流程。对于大多数应用搜索场景来说,其效果优于预期;但在那些对相关性建模要求较高的场景中(如网络规模搜索、具有复杂语义的文档检索),则存在局限性。

该 API 仅支持 REST 接口,官方 SDK 提供 JavaScript、Python、Ruby、PHP、Go、Rust、Java、Swift 和.NET 版本。在存储层,所有操作均为异步处理——执行操作后会返回一个任务 ID,需通过轮询来确认操作是否完成;而一旦索引进入稳定状态,读取操作则完全同步且即时完成。

在编写任何代码之前的架构设计

在开始配置之前,有必要了解 Meilisearch 的实际部署方式。虽然其概念比 Elasticsearch 更简单,但其中有一些细节需要妥善处理。一个 Meilisearch 进程负责管理一个或多个索引。索引的概念类似于关系型数据库中的表或 Elasticsearch 中的索引。每个索引都有其自身的设置:可搜索的属性、可过滤的属性、可排序的属性、排序规则、拼写错误容忍度配置以及同义词列表。各索引彼此独立——单个查询无法跨索引进行搜索(不过可以通过 multi-search 接口,在一次 HTTP 请求中执行多个查询)。

文档是存储在索引中的 JSON 对象。每个文档都必须有一个主键——即 Meilisearch 用作唯一标识符的字段。你可以手动指定主键,或者让 Meilisearch 通过查找名为 id、uid、objectId、primaryKey 或 {indexName}_id 的字段来自动推断主键。正确设置主键非常重要:如果 Meilisearch 选错了主键字段,那么唯一的补救办法就是重新建立索引。

任务即异步操作队列。当您添加文档、更新设置或删除索引时,Meilisearch 会将相应的任务加入队列,并返回一个包含数字 ID 的任务对象。您可以轮询 GET /tasks/{taskId} 来查看任务状态,或在任何 SDK 中使用 waitForTask() 。实际上,对于中小型索引而言,大多数操作都在几毫秒内完成;采用异步模式是为了确保操作的正确性,而非因为用户通常需要等待。

索引过程中的数据流:

Documents (JSON) → HTTP POST /indexes/{uid}/documents
                 → Meilisearch parses, extracts fields
                 → Builds inverted index + field storage
                 → Task completes (status: succeeded)
                 → Documents immediately searchable
在单实例部署中,无需考虑副本配置、分片分配或节点拓扑结构。整个系统仅由一个进程和一个数据目录构成。

十分钟内开始跑步
启动运行中实例的最快方式:
在 Linux 上直接安装二进制文件:
curl -L https://install.meilisearch.com | sh
./meilisearch --master-key="your-master-key-here"

--master-key 标志非常重要。如果没有这个标志,Meilisearch 将处于开发模式运行,且不支持任何身份验证——在本地测试时可以,但在其他环境中则存在严重的安全风险。主密钥的长度必须至少为 16 字节(UTF-8 格式)。一旦设置完成,该主密钥即可授予完整的 API 访问权限;可以根据该主密钥为您的应用程序生成相应的 API 密钥。

Meilisearch 默认在 7700 端口上运行。输入 http://localhost:7700/health ,准备就绪后你会收到 {"status":"available"} 。

为首批文档建立索引
来看一个实际例子:食谱搜索 API。每份文档的结构如下:
{
  "id": 1,
  "title": "Spaghetti alla Carbonara",
  "description": "A classic Roman pasta dish with eggs, Pecorino Romano, guanciale, and black pepper.",
  "cuisine": "Italian",
  "difficulty": "medium",
  "prep_time_minutes": 15,
  "cook_time_minutes": 20,
  "tags": ["pasta", "roman", "eggs", "classic"],
  "rating": 4.8,
  "published_at": 1706745600
}

使用 JavaScript SDK ( npm install meilisearch ):
import { MeiliSearch } from "meilisearch";

const client = new MeiliSearch({
  host: "http://localhost:7700",
  apiKey: "your-master-key-here",
});

// Create or update the index
const index = client.index("recipes");

// Add documents — returns a task
const task = await index.addDocuments(recipes);

// Wait for indexing to complete
await client.waitForTask(task.taskUid);
console.log("Indexed", recipes.length, "documents");

文档被索引后,简单搜索只需一行代码即可完成:
const results = await index.search("carbonara");
console.log(results.hits); // Array of matching documents

响应中包含 hits (匹配到的文档)、 query (原始查询字符串)、 processingTimeMs (引擎端延迟)、 limit 、 offset 和 estimatedTotalHits。由于性能原因,最后一个字段是估算值而非精确计数——要获得精确计数,需要对整个索引进行扫描。

为真实搜索配置索引
默认设置适用于演示环境。在正式环境中进行搜索时,需要明确配置三项内容:哪些字段可搜索、哪些字段可过滤、以及哪些字段可排序。

await index.updateSettings({
  // Only search in these fields, in priority order
  searchableAttributes: [
    "title",
    "description",
    "tags",
    "cuisine",
  ],

  // These fields can be used in filter expressions
  filterableAttributes: [
    "cuisine",
    "difficulty",
    "tags",
    "rating",
  ],

  // These fields can be used in sort expressions
  sortableAttributes: [
    "rating",
    "prep_time_minutes",
    "cook_time_minutes",
    "published_at",
  ],

  // Ranking rules — this is the default cascade, shown explicitly
  rankingRules: [
    "words",
    "typo",
    "proximity",
    "attribute",
    "sort",
    "exactness",
  ],

  // Synonyms
  synonyms: {
    "pasta": ["noodles", "spaghetti", "penne"],
    "quick": ["fast", "easy", "simple"],
  },

  // Stop words — excluded from search
  stopWords: ["the", "a", "an", "and", "or", "of"],
});

重要操作提示: filterableAttributes 和 sortableAttributes 需要重新建立索引。当您向其中任何一个列表中添加新字段时,Meilisearch 会为索引中的每份文档重新构建相关的索引结构。在处理大型数据集时,这一过程可能需要数分钟时间,并会占用大量 CPU 资源。建议在流量较低的时段进行架构更改。

searchableAttributes 的排序方式会影响整体排名。在 attribute 的排名规则中,排名靠前的字段会被赋予更高的权重。将 title 置于 description 之前,意味着标题中的匹配内容会比描述中的匹配内容排名更高,这几乎总是正确的处理方式。

过滤、排序与多维搜索
Meilisearch 的过滤语法是一种简洁的表达式语言,支持以下比较运算符:IN、NOT、AND、OR 和 EXISTS。

// Boolean filter: Italian cuisine, medium or easy difficulty
const results = await index.search("pasta", {
  filter: 'cuisine = "Italian" AND (difficulty = "easy" OR difficulty = "medium")',
});

// Range filter: highly rated quick recipes
const quickHighRated = await index.search("chicken", {
  filter: "rating >= 4.5 AND prep_time_minutes <= 20",
});

// Array filter: recipes with specific tags
const tagged = await index.search("soup", {
  filter: 'tags IN ["vegan", "gluten-free"]',
});

// Sorting: newest first, then by rating
const sorted = await index.search("dessert", {
  sort: ["published_at:desc", "rating:desc"],
});

多维搜索——即在显示搜索结果的同时,还能按各属性值统计文档数量——正是 Meilisearch 在用户体验方面的优势所在:
const faceted = await index.search("italian", {
  facets: ["cuisine", "difficulty", "tags"],
  filter: "rating >= 4.0",
});

console.log(faceted.facetDistribution);
// {
//   cuisine: { Italian: 142, French: 38, Japanese: 27 },
//   difficulty: { easy: 89, medium: 78, hard: 40 },
//   tags: { pasta: 55, "gluten-free": 32, ... }
// }

这些统计信息会与搜索结果一同在同一个请求/响应过程中返回,无需额外查询。Elasticsearch 需要通过聚合功能来获取这些数据,虽然聚合功能功能更强大,但正确配置的难度也更大。Meilisearch 的分面功能更为简单,足以满足 90%的使用场景。

Geosearch
Meilisearch 通过名为 _geo 的专用字段提供一流的地理搜索功能。如果文档包含位置数据,请在索引过程中添加该字段:
{
  "id": 42,
  "name": "Trattoria da Mario",
  "cuisine": "Italian",
  "_geo": {
    "lat": 41.8902,
    "lng": 12.4922
  }
}

然后将 _geo 设为可过滤的属性,并使用内置的地理过滤功能:

// Restaurants within 5km of a point
const nearby = await index.search("pizza", {
  filter: "_geoRadius(41.8902, 12.4922, 5000)", // lat, lng, radius in meters
  sort: ["_geoPoint(41.8902, 12.4922):asc"],    // Sort by distance
});

// Bounding box search
const inArea = await index.search("", {
  filter: "_geoBoundingBox([45.5, -73.5], [45.4, -73.6])", // [topRight], [bottomLeft]
});

_geoPoint 排序键会返回那些在响应中包含 _geoDistance 字段的文档,该字段显示了距离参考点的距离——这正是“距离 X 公里”这类用户界面标签所需要的数据。

用于生产环境的 Scoped API 密钥
您的主密钥绝不能离开您的服务器。对于面向客户端的应用程序——包括那些直接调用 Meilisearch 的前端 JavaScript 代码——请生成具有受限权限的 API 密钥:
// Server-side: generate a search-only key
const searchKey = await client.createKey({
  description: "Frontend search key",
  actions: ["search"],         // Only allow search operations
  indexes: ["recipes"],        // Only allow access to this index
  expiresAt: null,             // No expiry — manage rotation manually
});

console.log(searchKey.key); // Use this in your frontend
对于多租户应用而言,不同用户应只能查看自己的数据。Meilisearch 支持租户令牌——即经过签名的 JWT,其中包含过滤表达式,该表达式会应用于使用该令牌发出的所有查询:
import { MeiliSearch } from "meilisearch";

// Server-side: generate a per-user tenant token
// This user can only search their own organization's documents
const tenantToken = client.generateTenantToken(
  searchKey.uid,       // Parent key UID
  [{ filter: `organizationId = ${org.id}` }], // Mandatory filter
  {
    apiKey: searchKey.key,
    expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), // 24 hours
  }
);

// Client uses this token — searches are automatically scoped
租户令牌是 SaaS 应用的正确实现方式。该过滤器经过加密签名,客户端无法擅自更改,这意味着即使用户自行构造 API 请求,也无法搜索到本应属于其他数据的数据。

多搜索端点
一个真正被低估的功能:只需一次 HTTP 请求,即可同时查询多个索引并返回所有结果:
const { results } = await client.multiSearch({
  queries: [
    {
      indexUid: "recipes",
      q: "chocolate",
      limit: 5,
    },
    {
      indexUid: "ingredients",
      q: "chocolate",
      limit: 5,
    },
    {
      indexUid: "articles",
      q: "chocolate cake history",
      limit: 3,
    },
  ],
});

// results[0] = recipe hits
// results[1] = ingredient hits
// results[2] = article hits

这就是构建全球搜索体验的方式——即通过一个搜索栏同时查询您的产品、文档和博客文章。每个查询都在服务器端独立并行执行;响应中会包含所有查询结果。无需任何前端处理。

注意事项
想直截了当地说明这些局限性,因为 Meilisearch 的文档对此描述得不够充分。这并非一款通用搜索引擎。Meilisearch 不具备类似 SQL 的查询功能,无法进行复杂的聚合操作,没有能够处理任意字段表达式的自定义评分函数,也没有机器学习相关的技术支持。如果你需要根据包含多个数字字段的复杂公式来对搜索结果进行排序——比如,根据评分、更新时间以及转化率来计算产品得分——那你会发现使用该引擎非常困难。Elasticsearch 的函数评分查询功能或 OpenSearch 的 Learning to Rank 插件在 Meilisearch 中都不存在。

索引大小受限于您的基础设施条件。当工作集能够全部容纳在 RAM 中时,基于 LMDB 的索引性能最佳。对于规模巨大的数据集——即包含数千万份文档、且许多字段为文本类型的数据集——Meilisearch 的运行成本会显著上升,因为需要足够的 RAM 来存储热索引页面。该引擎可通过内存映射 I/O 和操作系统页面缓存机制来处理超过 RAM 容量的数据集,但如果索引无法完全容纳在 RAM 中,查询延迟将会增加。Elasticsearch 基于磁盘的索引在处理大规模数据时表现更为出色。

没有分布式模式。Meilisearch 作为单个进程在单台机器上运行。该系统不支持原生分片功能,没有副本配置,也不具备内置的高可用性机制。对于写操作密集型任务或需要跨多台机器处理的数据集而言,这是一个严重的限制。该公司在路线图中提到了分布式功能的开发计划,但截至 v1.9 版本,该系统仍为单节点结构。对于大多数应用搜索场景来说,这一点无关紧要;但对于写操作量大的生产环境而言,这一信息则很重要。

向量搜索目前仍处于开发阶段。Meilisearch 在 1.6 版本中加入了混合搜索功能(将向量相似度与关键词搜索相结合),该功能在后续版本中得到了显著改进。不过,与 Qdrant、Weaviate 或 Pinecone 等专门的向量存储方案相比,向量搜索的实现尚处于起步阶段,不够成熟。如果您的核心需求是语义搜索,而非通过向量来增强关键词搜索功能,建议优先考虑专为向量搜索设计的数据库。

全局视野/整体情况
Meilisearch 的故事反映了基础设施领域更宏观的趋势:即化解复杂性。在 2010 年代的大部分时间里,如果需要搜索功能,人们都会选择 Elasticsearch——并非因为其复杂性是必需的,而是因为当时没有其他能达到生产级质量的产品。无论你的应用场景只是在一个拥有 5 万条记录的 SaaS 产品中添加搜索栏,你都不得不接受 JVM、集群模型、映射 API、查询 DSL 等所有相关技术。

Meilisearch 以及 TypeSense(另一款基于 Rust 的搜索工具)代表了另一种思路:这些工具刻意减少功能,但因此使得使用起来更加简单,查询结果也能更快获得。虽然这种取舍是真实存在的,但对于应用程序层的搜索需求——也就是绝大多数网页开发者实际面临的情况——这些牺牲是完全值得的。

问题不应该是“Meilisearch 是否优于 Elasticsearch”。这种提问方式是错误的。正确的问题应该是:您的搜索需求是需要分布式分析引擎的通用功能,还是只需要在有限的数据集上实现快速、能容错且支持多维搜索的功能?请诚实地回答这个问题,然后据此做出选择。

适用人群
如果正在开发各类 SaaS 产品、电子商务网站、文档门户、内部知识库或内容量较大的应用程序,并且需要搜索功能——那么 Meilisearch 应该是您的首选,而非 Elasticsearch。当需求增长到它无法满足时,您随时可以升级到更复杂的解决方案。但反过来做则困难得多。

如果已经在使用 Elasticsearch 进行应用搜索,且维护集群所花费的时间多于开发新功能的时间,那么迁移的难度其实比您想象的要小。Meilisearch 的 REST API 非常简单,客户端适配器通常可以将这两种引擎通过统一的接口来调用,从而让您在过渡期间同时使用它们。

如果您正在构建数据分析平台、日志聚合系统,或任何需要在对数亿份文档进行复杂聚合处理的同时还能实现全文搜索的系统——请继续使用 Elasticsearch。它是完成这项工作的理想工具。

快速参考清单
使用 --master-key 设置运行 Meilisearch——在本地开发环境之外绝不要使用无密钥模式。

在创建索引时显式声明 primary key ,不要让 Meilisearch 自行推断。

按优先级顺序配置 searchableAttributes (最重要的字段优先)。

在添加文档之前,请先添加 filterableAttributes 和 sortableAttributes ,以避免重新索引。

在生产环境中设置 --max-indexing-memory ,以防止索引重建期间出现内存溢出。

生成有作用域的 API 密钥——切勿将主密钥泄露给客户端。

使用租户令牌实现多租户数据隔离。

将数据导出到对象存储的过程实现自动化,以便用于灾难恢复。

使用 multiSearch 可在多个索引之间进行联合搜索。

监控任务队列——长时间运行的任务表明存在值得调查的索引压力问题。

在发布前,使用真实用户查询来测试对拼写错误和不同查询条件的容忍度。

在目标文档数量下,查看索引大小与可用 RAM 的关系。


最新版本:1.0
历经三年多的开发,Meilisearch 1.0 首个完全稳定版已于2023年2月中旬正式发布,可用于生产环境且向前兼容。1.0 的 CLI 工具不仅进入了稳定状态,还新增了许多破坏兼容性的重要变化,使错误处理程序更加直观。开发团队也表示,在 v2.0.0 发布之前,未来的 CLI 版本不会再有破坏性的更改。


优化了索引和搜索速度,主要包括以下方面:
优化包含多个长单词的搜索请求的内存使用
提升 exactness 包含多个单词的搜索请求的排序规则性能
在解释搜索查询时将多词同义词翻译成短语。此项变更优化了结果的相关性,并改进包含多词同义词的搜索查询延迟的稳定性,从而消除 DoS 攻击的来源
优化 proximity 以短词结尾的搜索请求的排序规则性能
限制在不需要时更新设置引起的重新索引
减少邻近排序规则的增量索引时间
改进软删除 (soft-deletion) 计算
完整发布说明请参考此处

项目主页:https://github.com/meilisearch/meilisearch/