一百行 JavaScript 写一个能用的站内搜索
发布 分类 coding
当前浏览器未执行 JavaScript,下面是本篇的 Markdown 原文,内容一字未改。
静态站最难自给自足的功能就是搜索。要么依赖第三方服务,要么自己写一个。我选了后者,结果比想象中短。
<!-- more -->
## 索引长什么样
对每篇文章抽出四个字段,各给一个权重:
| 字段 | 权重 | 理由 |
| --- | --- | --- |
| 标题 | 10 | 命中标题几乎就是用户想找的 |
| 标签 + 分类 | 6 | 主题词命中很有价值 |
| 摘要 | 3 | 作者自己提炼过 |
| 正文 | 1 | 兜底,噪声最多 |
## 中文怎么分词
不引入词典,用**单字 + 重叠二元组**:
```js
function tokens(s) {
var out = [], cjk = s.match(/[\u4e00-\u9fa5]+/g) || [];
cjk.forEach(function (run) {
for (var i = 0; i < run.length; i++) {
out.push({ t: run[i], w: 0.35 }); // 单字,弱
if (i + 1 < run.length) out.push({ t: run.slice(i, i + 2), w: 1.6 }); // 二元组,强
}
});
(s.match(/[a-z0-9_]+/gi) || []).forEach(function (w) {
out.push({ t: w.toLowerCase(), w: 1 });
});
return out;
}
```
「并查集」会被拆成 `并`、`查`、`集`、`并查`、`查集`。搜「查集」时二元组命中,权重立刻拉开,结果天然正确。
## 打分与排序
```js
score = Σ(命中词权重 × 字段权重) × 标题奖励 × 长度惩罚
```
- **标题奖励**:标题里出现查询的二元组,乘 1.35。
- **长度惩罚**:`max(1 / (1 + ln(1 + len / 1200)), 0.7)`,避免长文靠字数堆分。
- **并列兜底**:按日期倒序,保证结果稳定可复现。
## 三个容易踩的坑
1. **正则元字符**:用户会输入 `[](){}+*?` 甚至 `\`,直接把查询塞进 `new RegExp` 会抛异常。必须先字面化转义。
2. **高亮二次包裹**:先标子串再标分词,会把 `<mark>` 套成 `<mark><mark>…</mark></mark>`。改成单次正则 + 跳过已标记区间。
3. **大小写与去重**:同一篇文章在标题和正文都命中时只应出现一次,取最高分那次。
## 性能
500 篇 × 3000 字的建索引实测在 200ms 量级,检索 10ms 以内。对个人站来说完全够用,还能做到**离线可用**——搜索是纯内存计算,不需要网络。
> [!TIP]
> 别忘了在索引里剔掉代码块与标记符号,否则用户搜 `div` 会把所有贴过 HTML 的文章全命中。