做了几年站的朋友大多有过这种体验:文章写了三百篇,访客进来想找一篇两年前写过的教程,站上那点自带的搜索框要么搜不到,要么翻半天翻不出来。PHP 自带的 LIKE 模糊查询在几百篇的小站还凑合,一旦上了千篇、正文又是长文,MySQL 全表扫一遍就是几秒的等待,而且中文分词这种事儿它压根不管——你搜"服务器优化",它未必能匹配到"优化服务器性能"这样的内容。
这篇文章讲的是一个更专业的解法:用 Typesense 给个人站搭一套独立的站内搜索服务。Typesense 是一个开源的、用 C++ 写的搜索引擎,主打"开箱即用"——不用像 Elasticsearch 那样调 JVM 堆、不用装繁重的集群,一个二进制文件加一份配置文件就能跑起来,对个人站长非常友好。下面从原理讲到落地,再到跟站点对接的完整流程。
一、为什么不用 MySQL LIKE,也不用 Elasticsearch
先说清楚选型。站内搜索本质上是三件事:倒排索引、中文分词、相关度排序。MySQL 的 LIKE '%关键词%' 三样一个都不占——它既不能建倒排索引(前置通配符直接放弃索引走全表扫),也不做中文分词,更没有相关度打分,返回结果的顺序完全是主键顺序。
Elasticsearch 当然能解决这些问题,但它对个人站是"杀鸡用牛刀":默认要吃一两个 G 内存跑 JVM,配置文件一屏接一屏,版本升级还经常把你辛苦攒的索引格式改掉。Typesense 的定位刚好卡在中间——它内部用内存加磁盘混合的方式存索引,单机几百万文档毫无压力,安装只需要下载一个静态编译好的二进制,改一个 YAML 配置文件。
Typesense 有几个特性正好戳中个人站的需求:
- 容错搜索(typo tolerance):用户把"nginx"打成"ngnix",它照样能搜到,内置编辑距离匹配;
- 前缀搜索:用户输入一半就开始出结果,这就是所谓"搜索即输入"(search-as-you-type)体验;
- 中文分词友好:内置对 CJK 的支持,配合查询时切词策略,中文内容能正常命中;
- API 极简:所有操作走 HTTP + JSON,一个 curl 就能建索引、导数据、查询,跟任何语言都能对接;
- 无外部依赖:不依赖 Java、不依赖 JVM,一个进程搞定。
二、安装与启动:一个二进制加一个配置文件
Typesense 官方提供 Linux 的静态二进制和 Docker 镜像。个人站我推荐直接用 Docker,隔离干净、升级方便。下面是 docker-compose 的写法:
services:
typesense:
image: typesense/typesense:27.1
restart: unless-stopped
ports:
- "127.0.0.1:8108:8108"
volumes:
- ./typesense-data:/data
command: >
--data-dir /data
--api-key=ChangeMeToARandomKey
--enable-cors
几个要点必须讲清楚。第一,端口只绑 127.0.0.1。Typesense 默认没有任何用户体系,一个 API Key 就是全部凭证,8108 端口如果暴露到公网,等于把你整个站的内容库和索引管理权限送人——任何人都能删库、重建、随便灌数据。需要从外部访问的话,正确的做法是套一层 Nginx 反代,加 IP 白名单或者 basic auth。
第二,API Key 一定要换成随机值。生成可以用 openssl rand -hex 32,千万别用教程里示例的 ChangeMeToARandomKey 直接上线。这个 key 拥有全部管理权限。
第三,--enable-cors 只有在你要在浏览器端直连 Typesense(把 search key 暴露给前端)时才需要。这里有个关键安全概念叫 scoped API key(作用域 API Key):Typesense 允许你用管理 key 生成一个只能搜索、不能写入、且只能访问指定集合的搜索专用 key,前端拿这个 key 是安全的。后面第四节会展开。
启动命令:
docker compose up -d
curl http://127.0.0.1:8108/health
返回 {"ok":true} 就说明服务起来了。这个 health 检查很关键——它不验证 key,用来判断进程是否存活;真正验证连通性要用带 key 的请求,比如 curl -H "X-TYPESENSE-API-KEY: 你的key" http://127.0.0.1:8108/collections。
三、建集合(Collection)与导入文章数据
Typesense 里一个"集合"(collection)类似于关系库的一张表,但它是 schema-less 的(可以显式定义也可以自动推断)。对于文章站,我们要定义好字段的用途:
- 哪些字段可以搜索(正文、标题)——用
facet、searchable标记; - 哪些字段只是作为结果显示(URL、发布时间、缩略图);
- 哪些字段要支持分面过滤(比如按分类、按标签筛选)——这些要用
facet: true; - 哪些字段是排序依据(发布时间)。
建集合的请求:
curl "http://127.0.0.1:8108/collections" \
-X POST \
-H "X-TYPESENSE-API-KEY: ${TS_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "articles",
"fields": [
{"name": "title", "type": "string"},
{"name": "content", "type": "string"},
{"name": "category","type": "string", "facet": true},
{"name": "tags", "type": "string[]", "facet": true},
{"name": "url", "type": "string", "index": false},
{"name": "created", "type": "int32"}
],
"default_sorting_field": "created"
}'
这里要注意 url 字段的 "index": false——URL 我们只需要它作为返回值展示,不需要被搜索、也不需要参与排序,关掉索引能省内存。同理,如果你有正文以外的摘要字段只作展示,也应该关掉索引。
导入数据用 JSONL(每行一个 JSON 对象)格式,一个文件里几百上千篇一次性灌进去:
curl "http://127.0.0.1:8108/collections/articles/documents/import?action=upsert" \
-X POST \
-H "X-TYPESENSE-API-KEY: ${TS_KEY}" \
-H "Content-Type: text/plain" \
--data-binary @articles.jsonl
action=upsert 是这个导入接口最关键的一个参数。不写 action 的话默认是 create,遇到已存在的 id 会直接报错中断整批导入。用 upsert 表示"存在就覆盖、不存在就插入",这样你可以把增量更新和全量重建用同一套逻辑——每天定时跑一次,新增的文章进去、改过的文章更新、删掉的文章再单独处理。每篇文档必须有一个唯一的 id 字段,个人站用数据库的 cid 或者文章 slug 都行。
四、前端对接与安全:scoped API key
这是最容易出安全问题的一环。很多教程为了图省事,直接把管理 API Key 写进前端 JavaScript——这等于把你的搜索服务整个交给了任何打开 F12 的人。正确做法是分层:
- 管理 key(admin key):只在服务器端使用(导入数据、改 schema),绝不进前端;
- 搜索 key(search-only key):通过管理 key 生成,权限被限制成"只能查询 search_only 的集合、只能读不能写",可以安全地放到前端。
生成搜索 key:
curl "http://127.0.0.1:8108/keys" \
-X POST \
-H "X-TYPESENSE-API-KEY: ${TS_KEY}" \
-H "Content-Type: application/json" \
-d '{
"description": "Search-only key for frontend",
"actions": ["documents:search"],
"collections": ["articles"]
}'
actions 只给 documents:search,collections 限定成 articles,这样即便 key 泄露,攻击者也只能查、不能改、不能碰别的集合。这是 Typesense 权限模型的核心价值。
前端查询示例(search-as-you-type):
async function doSearch(q) {
const url = 'https://search.yoursite.com/collections/articles/documents/search'
+ '?q=' + encodeURIComponent(q)
+ '&query_by=title,content'
+ '&per_page=10'
+ '&prefix=true';
const r = await fetch(url, { headers: { 'X-TYPESENSE-API-KEY': SEARCH_KEY } });
const data = await r.json();
return data.hits.map(h => h.document);
}
query_by=title,content 决定了搜索在哪些字段上匹配,字段顺序即权重顺序——title 在前表示标题命中权重更高。prefix=true 开启前缀匹配,这是"输入即搜"的关键。per_page 建议前端设小一点(10 左右),因为用户边打字边触发搜索,每次返回太多反而慢。
Nginx 反代的写法(给前端一个同域路径,避免跨域和 CDN 缓存问题):
location /ts/ {
proxy_pass http://127.0.0.1:8108/;
proxy_set_header Host $host;
proxy_set_header X-TYPESENSE-API-KEY $http_x_typesense_api_key;
proxy_cache off;
}
注意搜索请求不能被 CDN 或 Nginx 缓存,所以要显式关掉缓存。另外前端如果把自己的 X-TYPESENSE-API-KEY 头透传,反代要记得保留该头,别在转发时丢掉。
五、中文收录与五个必踩的坑
中文内容能不能搜准,是个人站最关心的问题。Typesense 处理 CJK 的基本策略是按字符切分(因为中文没有天然的空格分词),实践中有几个要点:
- 不要在导入时把正文里额外的 HTML 标签带进去。灌进索引的应该是纯文本或至少去掉 script/style 的干净内容,否则标签字符会污染分词结果;
- query_by 里把标题和正文分开,让标题命中权重更高,能显著改善相关度;
- 如果发现中文搜索结果不理想,可以尝试把整句切成二元组(bigram)后再查,但对于个人站长文来说,Typesense 的内置 CJK 处理配合 prefix 搜索通常已经够用。
再说五个实际踩过的坑:
- 把 8108 暴露公网且用默认 key——最危险的一个,直接裸奔。绑回环加反代白名单。
- 导入时忘了 action=upsert——第二次跑导入脚本报"document already exists"然后整批中断,白白浪费时间。养成永远带 upsert 的习惯。
- 把管理 key 写进前端——看似跑得通,实际是把管理权限送人。用 scoped key,只给 documents:search。
- 增量导入时旧文档不删除——文章删了但索引里还在,用户搜出来点进去 404。要在导入流程里同步比对、删除已下架的文档 id。
- 数据目录没做持久化或没备份——typesense-data 目录是全部索引所在,容器重建如果没挂 volume 就前功尽弃;即便挂了 volume,也要把索引目录纳入日常备份(用 rsync 或定时快照都行)。
六、效果与总结
给个人站加站内搜索,真正值得投入的理由不是"好看",而是留住访客。一个内容站积累到几百上千篇文章后,导航和分类已经不足以帮用户找到目标内容;搜索引擎进来的是新访客,而站内搜索服务的是回访用户和深度读者。把这部分体验做好,才能真正把流量沉淀成忠实读者。
Typesense 在这个场景下的优势很明显:部署成本低(一个二进制或一个容器)、内存占用小(个人站几百 MB 级别)、API 简单到几乎不用学、权限模型清晰。它不是要取代 Elasticsearch 那种大型全文检索平台,而是给个人站长一个"刚好够用、能自己维护"的搜索引擎。整套流程下来,从装到站点上能搜,一个下午就能搞定。