第 33 章 搜索:本地全文索引与在线搜索
本章目标
- 用 SQLite FTS5 做本地搜索(标题 / 艺人 / 专辑)。
- 处理中文分词:2-gram。
- 前端搜索框:防抖、键盘快捷键、结果分组。
- 融合在线 Provider 搜索结果。
一、FTS5 回顾
第 19 章已建:
CREATE VIRTUAL TABLE songs_fts USING fts5(
title, artist, album, content='', tokenize='unicode61'
);
unicode61 对英文、拉丁字母很好,但对中文"无助"——它只能整行匹配。方案:自己做 2-gram(把 "起风了" → "起风 风了")。
二、触发器保持同步
CREATE TRIGGER songs_ai AFTER INSERT ON songs BEGIN
INSERT INTO songs_fts(rowid, title, artist, album)
VALUES (new.id,
gram2(new.title),
gram2((SELECT name FROM artists WHERE id = new.artist_id)),
gram2((SELECT title FROM albums WHERE id = new.album_id)));
END;
gram2 不是 SQLite 内置函数,要通过 sqlx 注册自定义函数,或者在 Rust 层构造好字符串再 insert(更简单)。CloudTone 选后者:
pub fn gram2(s: &str) -> String {
let chars: Vec<char> = s.chars().collect();
if chars.len() <= 1 { return s.to_string(); }
let mut out = String::new();
for w in chars.windows(2) {
out.push(w[0]); out.push(w[1]); out.push(' ');
}
out.push_str(s); // 也保留原词,便于英文匹配
out
}
三、重建索引
// core/db/search.rs
pub async fn rebuild_fts(pool: &SqlitePool) -> sqlx::Result<()> {
sqlx::query("DELETE FROM songs_fts").execute(pool).await?;
let rows = sqlx::query_as::<_, (i64, String, Option<String>, Option<String>)>(
"SELECT s.id, s.title, a.name, al.title
FROM songs s LEFT JOIN artists a ON s.artist_id = a.id
LEFT JOIN albums al ON s.album_id = al.id"
).fetch_all(pool).await?;
for (id, t, ar, al) in rows {
sqlx::query("INSERT INTO songs_fts(rowid, title, artist, album) VALUES (?, ?, ?, ?)")
.bind(id)
.bind(gram2(&t))
.bind(ar.as_deref().map(gram2).unwrap_or_default())
.bind(al.as_deref().map(gram2).unwrap_or_default())
.execute(pool).await?;
}
Ok(())
}
四、查询
pub async fn search_local(pool: &SqlitePool, q: &str, limit: i64) -> sqlx::Result<Vec<Song>> {
let q_gram = gram2(q);
sqlx::query_as::<_, Song>(
"SELECT s.id, s.title, a.name AS artist, al.title AS album, s.artist_id, s.album_id,
s.path, s.duration_ms, s.track_no,
EXISTS(SELECT 1 FROM favorites WHERE song_id = s.id) AS liked, al.cover_path
FROM songs_fts f
JOIN songs s ON s.id = f.rowid
LEFT JOIN artists a ON s.artist_id = a.id
LEFT JOIN albums al ON s.album_id = al.id
WHERE songs_fts MATCH ?
ORDER BY rank LIMIT ?"
).bind(q_gram).bind(limit).fetch_all(pool).await
}
FTS5 的 rank 是 bm25 相关度,越小越相关(注意是"越小越好")。
五、命令 & 前端 Hook
#[tauri::command] #[specta::specta]
pub async fn search(state: tauri::State<'_, AppState>, q: String) -> Result<SearchResult, String> {
let q = q.trim().to_string();
if q.is_empty() { return Ok(SearchResult::default()); }
let local = search_local(&state.db, &q, 50).await.map_err(|e| e.to_string())?;
Ok(SearchResult { local, online: vec![] })
}
// src/features/search/useSearch.ts
import { useQuery } from "@tanstack/react-query";
import { commands } from "@/lib/ipc";
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
export function useSearch(q: string) {
const debounced = useDebouncedValue(q, 200);
return useQuery({
queryKey: ["search", debounced],
queryFn: () => commands.search(debounced),
enabled: debounced.length > 0,
});
}
useDebouncedValue 自己写(第 44 章展开):
import { useEffect, useState } from "react";
export function useDebouncedValue<T>(v: T, ms = 200) {
const [d, setD] = useState(v);
useEffect(() => { const t = setTimeout(() => setD(v), ms); return () => clearTimeout(t); }, [v, ms]);
return d;
}
六、UI
// src/components/shell/SearchOverlay.tsx
import { useHotkeys } from "@/hooks/useHotkeys";
import { useState } from "react";
import { useSearch } from "@/features/search/useSearch";
export function SearchOverlay() {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const { data } = useSearch(q);
useHotkeys("mod+k", () => setOpen(true));
useHotkeys("escape", () => setOpen(false), { enabled: open });
if (!open) return null;
return (
<div className="fixed inset-0 bg-black/60 z-50 flex items-start justify-center pt-24" onClick={() => setOpen(false)}>
<div className="w-[620px] bg-surface-2 rounded-xl shadow-2xl" onClick={e => e.stopPropagation()}>
<input autoFocus value={q} onChange={e => setQ(e.target.value)}
placeholder="搜索歌曲、专辑、艺人"
className="w-full px-4 py-3 bg-transparent border-b border-white/10 outline-none" />
<div className="max-h-96 overflow-y-auto">
{data?.local.map(s => (
<SongRow key={s.id} song={s} onClick={() => { playSong(s.id); setOpen(false); }} />
))}
</div>
</div>
</div>
);
}
七、在线 Provider(预留)
第 38 章会实现 Provider 接口:
#[async_trait::async_trait]
pub trait MusicProvider: Send + Sync {
async fn search(&self, q: &str) -> anyhow::Result<Vec<SongMeta>>;
async fn fetch_lyrics(&self, song: &Song) -> anyhow::Result<String>;
async fn fetch_cover(&self, song: &Song) -> anyhow::Result<Vec<u8>>;
}
search 命令可并发调用本地 + 若干 provider,tokio::join!,合并去重后返回。
八、高亮匹配片段
前端渲染时,把 query 的每个字符拆成 <mark> 包裹。简化版:
function Highlight({ text, q }: { text: string; q: string }) {
if (!q) return <>{text}</>;
const re = new RegExp(q.split("").map(escape).join(".*?"), "i");
const m = text.match(re);
if (!m) return <>{text}</>;
const i = text.indexOf(m[0]);
return <>{text.slice(0, i)}<mark className="bg-brand-500/40">{m[0]}</mark>{text.slice(i + m[0].length)}</>;
}
本章小结
- FTS5 + 2-gram 是中文小数据量下最省心的方案。
- 搜索体验的关键:快捷键、防抖、结果分组。
- 本地优先、在线补足,体验一致。
动手时刻
- 扫描 500 首后搜一个关键词,验证耗时。
- Cmd+K 打开搜索弹窗,Enter 播放第一条。
下一章:歌单与拖拽排序。