在 2核3.5G 的老服务器上搭1个中文全文搜索器

2026-10-19AI技研
粘贴图片
## 一、先认清机器的脾气

这台阿里云主机是 Alibaba Cloud Linux 3,2 核、3.5G 内存(可用约 2.3G)、磁盘剩 14G,没有 Docker 也没有 podman,当前用户是 admin 而非 root,上面已经常驻着 nginx、MariaDB、frps、gunicorn 好几个服务。

这三条约束决定了后面所有选型:

  • 装不了重型引擎。 Elasticsearch / OpenSearch 内存不够;Meilisearch 这种单二进制引擎本该最合适,但 download.meilisearch.com 返回 000,根本下不下来。
  • pip 必须走镜像。 直连 pypi 首页要 8 秒,pip install jieba 会 120 秒超时;换成 -i https://mirrors.aliyun.com/pypi/simple/ 秒级完成。
  • 包只能装在用户目录。 /usr/local/lib/python3.14/site-packages 对 admin 不可写,依赖全落在 ~/.local/...,systemd 单元里必须显式写 Environment=PYTHONPATH=...,否则服务起来就 ImportError。

方案最终收敛到:Python + SQLite FTS5 + 中文二元组,两个常驻服务合计内存不到 100MB。

二、坑一:SQLite 3.26 没有 trigram,中文分词只能自己扛

FTS5 可用,但 trigram 分词器要 3.34+,remove_diacritics 2 要 3.32+(实测直接报 "error in tokenizer constructor")。剩下只有 unicode61——它对中文是整段切分,等于不分词。

第一版用 jieba 在索引侧和查询侧都分词,踩了词典分词的固有缺陷:原文「城市轨道交通客运量」被切成 城市|轨道交通|客运量,用户搜「城市轨道」时 jieba 当成一个词,它跨越了索引侧的词边界,前缀通配也匹配不到(索引里只有 城市 和 轨道交通),零命中。这不是调参能解决的。

改成字符二元组:中文按相邻两字建索引,英数保留整词加前缀通配,jieba 只留在查询侧切长串。检索退化成子串匹配,不依赖任何词典。

代价是没有词边界语义——搜「制造业」786 条命中里有 468 条是「非制造业」。但比零命中值得。

三、坑二:错标签比搜不到更糟

资料库里大量 Sheet 把多张表上下堆叠。只认块首表头时,后一张表的表头被当数据吞掉,其后所有行沿用前一张表的列名。实测 21 个 Sheet / 38 处错位:

制造业PMI=50.2        ← 实际是非制造业指数
泊位数(个)=70.94     ← 实际是公共汽电车万辆
中国乡镇数=8517893     ← 实际是企业法人单位数

错标签产生的是假阳性——搜「制造业PMI」拿到非制造业的数,比搜不到危险得多。

修复要识别"块内第二段表头",判据是「本行全文本 + 下一个非空行含 ≥2 个数值」。两个附带教训:整行清空列名会让并排表退化成裸值(实测有标签行 -53、裸值行 +68),等于把错标签换成没标签,所以必须只重置新表头自己跨越的列区间;_NUMERIC_RE 必须把 ≈0.49 算作数值,否则「速动比率|≈0.49|≈0.48」整行被判全文本,误认成表头。

同类问题还有并排表共用的标题行:全库 161 行"两格全文本",其中 158 行是键值对数据(创始人 | 闫俊杰),只有 3 行是真标题。按格数直接判会毁掉整张键值对表。

四、坑三:Excel 把 2022.10 存成了 2022.1

时间列用数值型存 年.月 时,2022.10 的尾零被浮点吃掉变成 2022.1,与一月完全同值。Excel 靠 number_format='0.00' 显示成"2022.10"——用户看到的是对的,底层存的是错的,pandas 读到的就是 2022.1。

后果:搜「2022.10」全库 0 命中,搜「2022.1」拿到的是 10 月的数。

我一开始想用 number_format 当判据,扫描后发现不行:CPI/PPI 有 8 个 10 月格、PMI 2025 有 3 个都是 General 格式,按格式判会全漏;而且 pandas 路径根本拿不到格式。

真正可靠的是同列行序:后邻 Y.11 或前邻 Y.9 → 10 月;后邻 Y.2 或前邻 (Y-1).12 → 真 1 月。文本型月份也要纳入序列(社融时间列 64 格里 63 格是文本 '2018.10')。全库 109 个歧义格,106 格可判,改写 55 格 / 8 个文件,3 格判不出的原样保留——宁可不改,不能把真 1 月错标成 10 月。

五、坑四:pkill -f 把自己杀了

部署时执行 pkill -f "python3 app.py",模式串恰好出现在承载它的那条 bash -c 命令行里,pkill 把自己的父 shell 杀了。整条命令以 Signal 15 中断,后面的 cp 单元文件、daemon-reload、enable 全没执行,只留下一行输出——极容易误判成"部署失败原因不明"。

正确做法:pgrep -af "[p]ython3 app.py" 用括号技巧避免自匹配,拿到 PID 再 kill;systemd 服务直接用 systemctl。

六、网页搜索:唯一走得通的是抓 Bing

还想要一个能查 bing/百度的搜索器。实测各引擎:

| 引擎 | 结果 | |---|---| | 百度 | 302 → wappass.baidu.com 图形验证码 | | DuckDuckGo / Google | 000,不可达 | | Bing 官方 API | 主机可达,但没有订阅密钥 | | cn.bing.com | 200,103KB,10 个结果块 |

只剩抓 Bing SERP HTML 一条路。解析时又踩一个坑:get_text(" ", strip=True) 会在 <strong> 标签边界插空格,把「制造(汉语词语)」变成「制造 (汉语词语)」。中文必须无分隔符拼接再折叠空白——但也不能 strip=True,否则英文 Focus <strong>on </strong>Industry 会粘成 FocusonIndustry。

因为是抓页面不是调 API,Bing 改版就会失效。防护是解析器写成纯函数、用固定 fixture 守住,抓取异常一律抛错返回 502,绝不返回空列表冒充"无结果"。

七、几条通用教训

  1. 改完解析逻辑必须重启服务再重建索引——进程持有启动时载入的旧模块,不重启就用旧逻辑把错数据写回。
  2. 验证要查数据库,不能看日志——logging.basicConfig 只在 CLI 入口里,走 systemd 时 INFO 全被丢弃。我一度以为修复没生效。
  3. 先在真实数据上量误判面,再定规则——161 行候选里 158 行是误判,凭直觉定的规则会毁掉整张表。
  4. 对账不能按时间字符串建字典——1 月和 10 月塌缩成同一个键会互相覆盖,我因此算出 13 处"数据不符"的假警报,其实数据是对的。

最终:search_api 在 :8090 索引 19 个文件 / 4519 条记录,web_search 在 :9000 合并网页与本机资料,两个服务都是 systemd 常驻、开机自启、崩溃自愈。

在一台配置尴尬的老机器上,真正的难点从来不是"用什么框架",而是认清环境给的边界,然后在边界内把数据本身的问题一个个啃掉。搜索引擎只是壳,数据质量才是里子。