给 1400 首歌做语义搜索:一个 QQ 机器人里的曲名检索设计
这篇讲它在曲名检索上的设计:字符串三层 + 向量兜底的混合架构,以及为什么我们做了向量检索,却没用任何向量数据库。
一、玩家搜歌的方式有多野
舞萌的曲库是个重灾区:
- 曲名本身是日文:
ハッピーシンセサイザ - 玩家会打简体:
千本樱(原曲是千本桜) - 会打罗马音:
senbonzakura、happy synthesizer - 会打拼音:
qianbenying - 甚至会打语义描述:「那个什么花少女的歌」
前几种用字符串技巧还能救。最后一种是字符串搜索天生无解的 —— 你没法穷举所有「玩家会怎么形容一首歌」。这才是我们引入 embedding 的真正动机:不是「向量检索很酷」,而是有一类查询字符串永远接不住。
二、整体架构:三层漏斗,向量只做兜底
关键决策是顺序,不是技术选型。
①② 覆盖了约 96% 的真实查询(拿生产环境 1394 首曲库验证过),③ 只服务「意译 / 语义描述」这类字符串救不回来的剩下 4%。
如果把向量层放前面,每次查询都要吃一次网络往返(embedding API 是远端服务),等于把 200ms 的成本摊到 100% 的查询上,只为解决 4% 的问题。
贵的层放最后,是检索系统设计的通用原则。
三、字符串层:先穷尽「便宜」的手段
在碰向量之前,把便宜的招数打满。核心是一条归一化管线:
几个值得说的点:
1. 简体 → 日文异体字映射表。 樱→桜、泽→沢、铁→鉄……只收「形近但字不同」的映射。同形字(花→花)是噪声,不收。这张表只能手工维护,因为中日汉字对应没有简单算法,也不存在权威的自动化映射。
2. 假名 → 罗马音静态表。 舞萌曲库里出现过的假名只有 152 种,全在标准五十音范围内。一个 dict 就够了,不需要引入任何分词库或罗马音转换依赖。用户打 senbonzakura,我们把曲名里的假名转成罗马音去比。
3. 装饰符号全丢。 ★☆♪、全角括号、长音符 ー……这些在曲名里大量出现,但玩家永远不会输入。让它们参与匹配只会添乱。
归一化之后,① 层做精确 / 前缀 / 子串匹配,② 层用 trigram + 编辑距离兜住错别字。
曲名是短文本,且高度非自然语言(假名、符号、英文混排)。jieba 这类分词器对曲名的切分结果不稳定,反而会引入噪声;曲库规模也不值得上 SQLite FTS 之类的索引。归一化 + trigram 在这个量级已经够用。
四、向量层:只对「漏网之鱼」出手
4.1 离线算一次,在线算一次
成本控制的核心就这一句话:
- 曲库向量离线算一次:跑
scripts/build_music_vectors.py,把全部曲名批量交给 embedding API 算好,存进本地数据库。换模型或改文本时用--rebuild-stale增量重算。 - 在线只算查询那一句:用户发「那个什么花少女」,只对这一句话调 1 次 embedding API,然后和本地的 1400 条向量比余弦相似度。
一次 embedding 调用(一句话、1024 维)按量计费基本可以忽略;如果你在本地跑模型(比如 bge-m3),成本是零,代价只是内存和启动时间。
4.2 喂给模型的文本是「人可改」的
存库里有个 text_blob 字段,是真正喂给 embedding 模型的文本。它不是简单塞曲名,而是把归一化形、罗马音、拼音全拼进去 —— 让向量天然带上跨书写系统的语义。
这样 happy synthesizer 和 ハッピーシンセサイザ 即使字符串层没命中,向量距离也会很近。
这也是给某首歌补别名的地方:发现某首歌搜不到,就往 text_blob 里加别名,重算那一条向量。
text_blob 是人写的,embedding / model_name 是机器写的。
手动去编辑 embedding 里的浮点数没有任何意义 —— 向量是模型学出来的语义坐标,不是可以手调的参数。要改语义,改文本,然后重算。
4.3 为什么不用 faiss / chromadb / milvus
这是整个设计里最反直觉的部分:我们做了向量检索,但没有用任何向量数据库。
先算一笔账:
| 项 | 数值 |
|---|---|
| 曲库规模 | 1394 首 |
| 向量维度 | 1024 |
| 数据类型 | float32 |
| 总占用 | ≈ 5.7 MB |
| 暴力算余弦(Top-K) | ~1ms 量级 |
5.7MB 全部装进一个 numpy 矩阵,暴力算余弦相似度只要 1ms 量级 —— 比任何 ANN(近似最近邻)索引都快,而且是精确结果,零精度损失。
那引入 faiss 的成本呢?C++ 编译依赖(生产环境是 Python 3.14,wheel 覆盖不全)、安装体积、维护成本,而收益为零。就算曲库涨到 10 万首也才 400MB,暴力算仍是几毫秒。
真到百万级再换不迟。
「用向量检索」和「用向量数据库」是两回事。小数据集上,numpy 就是最好的向量数据库。
4.4 工程细节
- embedding 存 BLOB 不存 JSON:float32 二进制体积约是 JSON 文本的 1/3,
np.frombuffer零拷贝解析,启动加载 1400 条只要几毫秒。 - 热缓存:启动时把全量向量规整成一个 numpy 矩阵常驻内存,查询一次矩阵乘法出 Top-K。没装 numpy 时降级为纯 Python 循环 —— 慢一点,但能用。
- 阈值过滤:余弦相似度低于 0.55(可配置)视为「没找到」,避免强行返回不相关的结果误导用户。
- boost / disabled:每首歌可以设排序加权或直接屏蔽,给运营留手动干预的口子。
- 热生效配置:模型、Base URL、API Key、开关、阈值都存数据库,管理员在群里发
向量检索配置 模型 bge-m3就能改,不用重启。换模型名后需要跑一次重算(不同模型的向量空间不通用)。 - 失败静默降级:embedding API 挂了、没配置、向量库为空 —— 向量层一律返回空列表并记日志,字符串层照常工作。
向量检索是锦上添花。 它的任何失败都不应该影响查歌这个主功能。
生产环境里,一个「可选增强」把自己失败成主流程故障,是最常见的翻车方式。
五、和 NoneBot 异步模型的配合
最后一层工程约束:检索的同步路径里包含 DB 读和 numpy 计算,不能直接跑在事件循环里,否则会卡住整个 bot。
# search_sync 是同步实现,调用方必须用线程池包裹result = await asyncio.to_thread(search_sync, query)
# search_async 是现成的异步入口,内部就是 to_thread(search_sync)result = await search_async(query)
# embedding 客户端提供同步 embed_batch(批量、带二分重试),异步包装同样走线程池这延续了项目一贯的规矩:NoneBot 事件循环里禁止 PyMySQL、慢 SQLite、Pillow、numpy 大计算 —— 该进线程的进线程。
六、效果与总结
| 层 | 耗时 | 覆盖 |
|---|---|---|
| ① 字符串精确 / 前缀 / 子串 / 罗马音 / 拼音 | ~0.2ms | 绝大多数查询 |
| ② trigram + 编辑距离模糊 | ~30ms | 错别字、书写变体 |
| ③ 向量兜底(仅 ①② 都空时) | ~200ms+ | 意译、语义描述 |
总结:
- 先穷尽便宜的招,再上贵的。 归一化 + 罗马音 / 拼音映射能吃掉 96% 的查询,向量只做那 4% 的兜底。
- 小数据集不需要向量数据库。 几 MB 的矩阵,numpy 暴力算是最快、最准、依赖最少的方案。
- 离线算曲库、在线算查询。 把 embedding API 成本压到几乎为零。
- 喂给模型的文本是运营接口。
text_blob设计成「人可改」,别名补在那里,向量自然懂。 - 增强功能必须可降级。 向量层任何失败都静默返回空,主流程永不受影响。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!



