Meilisearch 给个人站做站内搜索:中文分词、输入即出结果与增量同步实战

为什么你的站内搜索「搜不到东西」,而别人的一秒出结果

几乎每个个人站长都遇到过同一个场景:访客在搜索框里输入「Nginx 缓存」,回车之后转圈三秒,回来一句话——「没有找到相关内容」。可你明明写过三四篇讲 Nginx 缓存的文章。更尴尬的是,访客改用 Google 搜「site:你的域名 nginx 缓存」,第一页就命中了。这说明问题不在内容,在搜索本身。

绝大多数小站用的所谓「站内搜索」,其实是数据库里一句 LIKE '%关键词%'。它是全表扫描 + 逐行字符串匹配的组合:数据量一上千行就开始变慢,中文没有分词所以「缓存」能命中「缓存」却命中不了「快取」,多个词之间是 AND 硬拼所以错一个字就零结果,而且它对搜索词的顺序、拼写误差、近义词毫无容错。一句话概括:数据库的 LIKE 根本不是搜索引擎,它只是碰巧能做字符串比对。

这篇文章不聊抽象概念,而是完整走一遍:在一台 1 核 2G 的便宜 VPS 上,用 Meilisearch 给一个静态博客/Typecho 站装上真正的站内搜索,做到中文分词、拼音容错、输入即出结果(search-as-you-type),并且把它和现有文章数据打通、增量同步、加上防滥用。整个过程不依赖 Elasticsearch 那套动辄要 2G 内存起步的重家伙。

先想清楚:站内搜索到底解决什么问题

在动手之前要先区分三种完全不同的需求,因为它们的方案完全不同。

第一种是「访客找你已经写过的东西」。这是转化率最高的一类搜索——访客明确知道自己要什么,能不能找到直接决定他留不留下来。这类搜索要求的是:快速返回、中文分词正确、允许打错字、能按相关度排序。

第二种是「访客不知道自己要什么,靠搜索探索」。这类更接近推荐,需要聚合、facets(分类筛选)、相关文章。小站通常不做,做了也是加分项。

第三种是「搜索引擎爬虫索引你的站」。这跟你页面上有没有搜索框毫无关系,别把 sitemap、IndexNow 那套和站内搜索混为一谈。

本文聚焦第一种。判断标准很朴素:如果你的访客在站内搜索框里搜一个你确实写过的标题关键词,结果却是空的,或者要等两秒以上才出来,那这个搜索就是负资产——它让访客确认了「这个站没有我要的东西」,然后离开。这比不放搜索框更糟。

为什么选 Meilisearch 而不是 Elasticsearch

选型这件事,个人站长最容易踩的坑是「照着大厂的教程做小站的事」。Elasticsearch 是给日志分析、PB 级检索设计的,它的 JVM 堆内存默认就要吃掉不少,跑在一个 1G 内存的 VPS 上会和你的 Web 服务抢内存,最后两边都卡。

Meilisearch 是一个用 Rust 写的搜索引擎,特点是:单个二进制文件、开箱即用、默认就带中文分词(靠内置的字符切分 + 可选的分词器)、内存占用小。它的定位就是「给应用加搜索」,而不是「做数据分析」。它有几点特别适合个人站长:

  • 零配置即可搜索:安装完启动,导入 JSON,直接就能用,不需要先定义 mapping、不需要建索引结构。
  • 原生支持错字容错(typo tolerance):搜「nginx cach」也能命中「nginx cache」,对拼音输入法的误触也友好。
  • 支持前缀搜索,天然适合「边输入边出结果」,做 search-as-you-type 不需要额外写代码。
  • 提供 HTTP 接口,任何语言、任何静态站都能调用,不需要装专门的客户端 SDK。
  • 官方提供 Docker 镜像,一条命令就能起。

它不适合的场景也要说清楚:如果你要做复杂的多表 join、聚合分析、复杂布尔嵌套查询,那还是 Elasticsearch 或直接上数据库。但对于「把一个站的文章标题和正文做成可搜索的」,Meilisearch 是性价比最高的选择。

用 Docker 起一个 Meilisearch(含持久化与主密钥)

最省事的部署方式是 Docker。但直接用官方那条示例命令会埋两个坑:一是没挂载卷,容器一重建索引全丢;二是没设主密钥,服务暴露在公网上任何人都能删你的索引。下面这条命令把这两件事都做掉。

docker run -d \
  --name meilisearch \
  --restart unless-stopped \
  -p 127.0.0.1:7700:7700 \
  -e MEILI_MASTER_KEY='换成你的长随机串' \
  -e MEILI_ENV=production \
  -v /opt/meili/data:/meili_data \
  getmeili/meilisearch:v1.6

逐条解释,这些细节决定了这套东西能不能长期稳定跑:

  • -p 127.0.0.1:7700:7700:注意这里绑定的是 127.0.0.1 而不是 0.0.0.0。Meilisearch 本身不应该直接对公网开放,所有请求都应该由你已有的 Nginx 反代进来。这样你可以复用现成的 HTTPS 证书、限流和访问日志,也不用额外开防火墙端口。
  • MEILI_MASTER_KEY:这是读写用的管理密钥。设成 production 环境之后,所有 API 请求都必须带这个密钥,否则返回 401。密钥请用随机串,别用「123456」这种。openssl rand -hex 32 生成一个即可。
  • -v /opt/meili/data:/meili_data:把数据目录挂到宿主机。Meilisearch 会把索引和元数据写在这里,不挂卷的话容器一删索引就没了,重建要重新导入全部数据。
  • --restart unless-stopped:服务器重启后自动拉起,避免「周一发现搜索挂了因为周末重启过」。

启动后先验证它活着:

curl -s http://127.0.0.1:7700/health
# 返回 {"status":"available"} 即正常

用 Nginx 反代并加上限流(防止被当成免费搜索 API 刷)

Meilisearch 暴露出去之后,它就是一个公开可调的搜索接口。如果完全不设防,有人可以写脚本高速调用,把你的 CPU 跑满——搜索是要算的,比静态页吃资源得多。所以反代层一定要有限流。

# 在 http 块里定义限流区(放在 nginx.conf 的 http { } 内)
limit_req_zone $binary_remote_addr zone=meili:10m rate=5r/s;
limit_req_status 429;

# server 块
server {
    listen 443 ssl http2;
    server_name search.example.com;

    location / {
        limit_req zone=meili burst=10 nodelay;

        proxy_pass http://127.0.0.1:7700;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # 搜索是 POST 请求,默认 body 大小上限要留足
        client_max_body_size 2m;
    }
}

这里有两个容易忽略的点。第一,limit_req 的 rate 是「每秒允许的请求数」,burst 是允许瞬间爆发的数量。输入即搜的场景每敲一个字符发一次请求,正常人打字速度下 5r/s + burst 10 足够用;如果你设成 1r/s,正常用户打字快一点就会被 429,体验反而更差。第二,nodelay 的含义是爆发量立刻处理而不是排队延迟,去掉它会导致突发请求被拖慢,搜索框卡顿。

另外注意 client_max_body_size:批量导入文章时发的 POST body 可能很大,如果这个值太小,导入接口会返回 413,而单条搜索却正常,很容易误判成「导入代码写错了」。

设计索引结构:把文章和搜索字段分开

接下来要把你站里的文章喂给 Meilisearch。核心是设计「文档结构」和「可搜索字段」。一个常见错误是把整篇 HTML 原样塞进去,结果搜索时 HTML 标签也被索引,相关度排序被污染;正确做法是只索引纯文本,但要保留标题的权重。

推荐的文章文档结构是这样的:

{
  "id": 1234,
  "title": "Nginx 反向代理配置实战",
  "slug": "nginx-proxy-pass",
  "url": "https://www.example.com/1234.html",
  "content": "这里是去掉所有 HTML 标签的纯文本正文……",
  "tags": ["Nginx", "反向代理", "缓存"],
  "category": "Linux 运维教程",
  "published_at": 1727481600
}

几个字段的作用值得单独说:

  • id:必须是唯一的,Meilisearch 用它做增量更新(同一个 id 再导入会覆盖而不是新增)。用你数据库里的 cid 最合适。
  • title 和 content 分开:这样才能让标题的相关度权重高于正文。如果混在一起,一篇正文里提了十次「缓存」的文章会把标题就叫《Nginx 缓存实战》的文章挤下去,结果明明标题匹配的排在后面。
  • url:搜索结果要能点进去,前端直接渲染这个字段,不用再回数据库查。
  • published_at:可以做成「按时间排序」的可选项,也方便做「最近更新」的排序规则。

导入之后,要显式告诉 Meilisearch 哪些字段可搜索、哪些可以用来过滤排序。这一步不做也能用,但默认行为不一定符合你的预期:

curl -X PUT 'http://127.0.0.1:7700/indexes/articles/settings' \
  -H 'Authorization: Bearer 你的主密钥' \
  -H 'Content-Type: application/json' \
  --data-binary '{
    "searchableAttributes": ["title", "tags", "content"],
    "filterableAttributes": ["category", "tags"],
    "sortableAttributes": ["published_at"],
    "displayedAttributes": ["id", "title", "url", "category", "tags"]
  }'

searchableAttributes 的顺序即权重:写在前面字段的匹配,优先级高于后面的。所以 title 排第一、tags 第二、content 最后,正好对应「标题命中 > 标签命中 > 正文命中」这个符合直觉的排序。

写同步脚本:把数据库文章灌进索引

索引结构定好之后,需要一个脚本把文章从数据库导出成 Meilisearch 期望的 JSON。下面是一个可以直接改用的 Python 骨架,它做了三件关键的事:去 HTML 标签、按批次导入、等索引完成再返回。

import re, json, time, urllib.request

MEILI = "http://127.0.0.1:7700"
KEY = "你的主密钥"
INDEX = "articles"

def strip_html(s):
    # 先把块级标签换成空格,避免两个词被粘成一个
    s = re.sub(r"<[^>]+>", " ", s)
    s = re.sub(r"&[a-z]+;", " ", s)
    return re.sub(r"\s+", " ", s).strip()

def upsert(docs):
    body = json.dumps(docs, ensure_ascii=False).encode("utf-8")
    req = urllib.request.Request(
        f"{MEILI}/indexes/{INDEX}/documents",
        data=body,
        headers={
            "Authorization": f"Bearer {KEY}",
            "Content-Type": "application/json",
        },
    )
    r = urllib.request.urlopen(req, timeout=60)
    task = json.loads(r.read())
    # 关键:拿到 taskUid 之后要轮询,等索引真的建完
    tid = task["taskUid"]
    while True:
        q = urllib.request.Request(
            f"{MEILI}/tasks/{tid}",
            headers={"Authorization": f"Bearer {KEY}"},
        )
        st = json.loads(urllib.request.urlopen(q, timeout=30).read())
        if st["status"] not in ("enqueued", "processing"):
            return st
        time.sleep(0.3)

这段代码里最值钱的是最后那个轮询循环。Meilisearch 的文档导入是异步的:POST 出去立刻返回一个 taskUid,但索引可能还在后台处理。如果你不等它完成就去搜,会搜不到刚导入的文章,然后你会以为是数据结构错了、到处排查——实际上只是时序问题。等到 status 变成 succeeded 再验证,才是可靠的。

导入完成后验证一下文档数量对不对:

curl -s 'http://127.0.0.1:7700/indexes/articles/stats' \
  -H 'Authorization: Bearer 你的主密钥'

返回里的 numberOfDocuments 应该和你数据库里的文章数一致。如果不一致,通常是 id 重复导致覆盖,检查一下你的 id 生成逻辑。

做「输入即出结果」的前端搜索框

Meilisearch 自带前缀搜索,做 search-as-you-type 只需要一个输入框加一段 JS。关键是要处理「打字快导致请求乱序返回」的问题——如果你不处理,用户敲了「ngin」,先发出的「ngi」请求可能后返回,结果闪回上一个词的搜索结果。

const MEILI = 'https://search.example.com';
const KEY = '你的「搜索专用」密钥';   // 不是主密钥!
let seq = 0;

const box = document.querySelector('#search');
const list = document.querySelector('#results');

box.addEventListener('input', async (e) => {
  const q = e.target.value.trim();
  if (q.length < 2) { list.innerHTML = ''; return; }

  const mySeq = ++seq;                  // 每次搜索递增序号
  const res = await fetch(`${MEILI}/indexes/articles/search`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      q,
      limit: 8,
      attributesToHighlight: ['title'],
      attributesToCrop: ['content'],
      cropLength: 40,
    }),
  });
  const data = await res.json();

  if (mySeq !== seq) return;            // 回来晚了,丢弃这次结果
  list.innerHTML = data.hits.map(h => `
    <a href="${h.url}">
      <b>${h._formatted?.title || h.title}</b>
      <small>${h._formatted?.content || ''}</small>
    </a>`).join('');
});

这段前端代码有三个必须理解的设计:

  • 序号丢弃机制(seq + mySeq !== seq):这是 search-as-you-type 的核心。网络请求的返回顺序不保证和发出顺序一致,只有记录「最新一次搜索的序号」,把过期结果丢掉,才不会出现结果闪烁。
  • 前端绝对不能用主密钥。主密钥能删除索引、修改配置,一旦放在 JS 里,任何人打开开发者工具都能拿到,等于把你的搜索服务交出去。正确做法是用 Meilisearch 的 /keys 接口生成一个「只允许对 articles 索引做 search 操作」的受限密钥,前端只用那个。
  • attributesToHighlight / attributesToCrop:让 Meilisearch 直接返回带高亮标记和裁剪后的摘要,前端不用自己实现关键词高亮。

生成受限搜索密钥的方式:

curl -X POST 'http://127.0.0.1:7700/keys' \
  -H 'Authorization: Bearer 你的主密钥' \
  -H 'Content-Type: application/json' \
  --data-binary '{
    "description": "前端搜索用",
    "actions": ["search"],
    "indexes": ["articles"],
    "expiresAt": null
  }'

返回里的 key 字段就是可以放进前端的密钥。它只能搜索、只能搜 articles 这一个索引,泄露了危害也有限。

增量同步:新增文章怎么自动进索引

全量导入只适合第一次。之后每发一篇文章都应该立刻进索引,否则访客搜不到最新内容——而新文章恰恰是最需要被发现的。最省事的做法是「先发布,再触发同步」:在发布文章的流程末尾,调用一次 upsert,只推这一篇。

把上面的导入改成单篇:

def index_one(cid, title, slug, url, html, tags, category, ts):
    doc = {
        "id": cid,
        "title": title,
        "slug": slug,
        "url": url,
        "content": strip_html(html),
        "tags": tags,
        "category": category,
        "published_at": ts,
    }
    return upsert([doc])   # 单篇也走同一个接口

然后在你发布脚本的最后加一行调用。这样每发一篇文章,索引就同步更新一篇,永远保持最新。用同一个 id 重复导入是覆盖语义,所以即使不小心跑了两遍也不会出现重复文档。

如果你没办法改发布流程(比如用的是现成 CMS),那就退一步:写个 cron,每隔十分钟跑一次「找出比上次同步时间更新的文章,逐篇 upsert」。用 published_at 或 updated_at 做水位线,逻辑简单又可靠。

一次真实的排查记录:搜不到中文,但英文正常

最后分享一个很有代表性的案例。某次部署完之后,站长发现:搜索英文关键词一切正常,搜中文却几乎全是空结果。他先是怀疑分词器没装,折腾了半天语言配置,问题依旧。

实际排查顺序是这样的:

第一步,确认数据里到底有没有中文。直接拿一篇确定含中文的文章 id,去数据库里查,中文在。排除「导入时中文丢了」。

第二步,确认索引里存的是什么。用 /indexes/articles/documents/那个id 接口把索引中的文档取出来看,发现 content 字段确实是中文,但所有中文词都被空格隔开了不该隔的地方,或者干脆是空字符串。到这里就锁定是导入环节的清洗函数问题。

第三步,回看清洗函数。问题出在 strip_html 里那句正则:[^>]+ 用的是贪婪匹配,遇到正文里一个没闭合的尖括号(比如文章里写代码示例时留下的),它会把从那个 < 开始到后面很远处才出现的 > 之间全部内容当成标签删掉,一次就吃掉大半篇正文。英文文章里这种情况少,中文文章里因为正文长、尖括号多,被误删得更严重。

修复。把贪婪改成非贪婪是治标,更稳的是先用一个成熟的方式处理:要么在入库前就把 HTML 转成纯文本(而不是在导入时用正则现剥),要么把正则收紧成只匹配真正的标签形态。

改造后的数据对比。同一个中文关键词,修复前返回 0 条,修复后返回 17 条,且标题匹配的排在前三。整个过程没有动搜索服务的任何配置——问题从头到尾都在「喂进去的数据」这一侧。

这个案例的启发很直接:搜索出问题,先去查索引里到底存了什么,而不是先怀疑搜索引擎。Meilisearch 对中文的支持是开箱可用的,绝大多数「中文搜不到」的真实原因,都出在导入清洗把中文吃掉了。养成「查看索引中的原文」这个习惯,能省掉大量无效的配置折腾。

小结与落地清单

把整套流程浓缩成一份可执行的清单:

  • 用 Docker 起 Meilisearch,务必挂载数据卷、设置 MEILI_MASTER_KEY、绑定 127.0.0.1 而不是 0.0.0.0。
  • 用 Nginx 反代 + limit_req 限流,避免搜索接口被刷爆 CPU。
  • 文章文档把 title、content、tags 分开,id 用数据库主键,保证可增量覆盖。
  • 通过 searchableAttributes 的顺序显式设定权重:标题 > 标签 > 正文。
  • 导入是异步的,必须轮询 taskUid 直到成功再验证,否则会误判。
  • 前端用受限的 search-only 密钥,并实现请求序号丢弃机制,主密钥绝不能进浏览器。
  • 新文章在发布流程末尾单篇 upsert,或用 cron 按时间水位线增量同步。
  • 中文搜不到时,第一件事是查索引里实际存了什么,而不是调分词器配置。

对个人站长来说,一个「输入两个字就秒出结果」的站内搜索,是少数几个投入产出比特别高的改造:它不占多少资源,实现不超过一天,但它直接决定了访客「在你的站里找不找得到答案」。数据库 LIKE 撑到几百篇就该换了,Meilisearch 是那个刚好够用、又不用你为运维买单的中间选项。

Last modification:September 28th, 2026 at 07:25 pm

Leave a Comment