本地优先的多层搜索架构
Deck 如何在纯本地环境下实现从精确匹配到语义搜索的全链路搜索——FTS5、模糊匹配、NLEmbedding 和 sqlite-vec。
搜索决定了一个剪贴板管理器好不好用。你一天可能复制几百条东西——如果找不到今早的那段代码,历史记录再多也是噪声。
云端应用可以把搜索丢给 Elasticsearch 或 Algolia。Deck 没有这个选项——它是一款 Swift / SwiftUI 构建的 macOS 原生剪贴板管理应用,所有数据都留在用户设备上。没有服务器,不发网络请求,没有遥测。搜索系统必须完全建立在 SQLite 和 Apple 原生框架之上。
本文介绍具体的实现方式。
挑战
本地搜索的难度远超直觉。想想一个剪贴板管理器要处理什么:
standard tokenizers choke on 你好世界.
- CJK 文本没有空格分隔——标准分词器在
你好世界面前束手无策。 - 拼写错误和模糊召回——用户输入"clpboard",仍然期望找到"clipboard"。
- 语义意图——搜索"网络错误"应该能召回"连接超时"相关的条目。
- 亚秒级延迟——搜索必须感觉是即时的,即使面对数万条记录。
- 零资源浪费——常驻后台的应用不能浪费 CPU 和内存。
Deck 的答案是一条五层搜索管线——每一层兜住上一层漏掉的结果。
架构总览
flowchart TD
Input["[?] User Query\n用户查询"] --> Parser["SearchRuleParser.parse\n输入解析"]
Parser --> Keyword["Keyword\n关键词"]
Parser --> Filters["Rule Filters\n规则过滤器\n(app / date / type / lang / size)"]
Keyword --> DB["DB Layer / 数据库层"]
DB --> FTS["FTS5 Full-Text Search\n全文检索 (BM25)"]
DB --> LIKE["SQL LIKE Fallback\nLIKE 回退"]
DB --> VEC["sqlite-vec Vector Search\n向量检索"]
FTS --> Candidates["Candidate Set\n候选集"]
LIKE --> Candidates
VEC --> Candidates
Candidates --> RuleFilter["SearchRuleFilters.apply\n规则过滤"]
Filters --> RuleFilter
RuleFilter --> MemSearch["In-Memory Search\n内存搜索"]
MemSearch --> Exact["exact\n精确匹配"]
MemSearch --> Regex["regex\n正则表达式"]
MemSearch --> Fuzzy["fuzzy\n模糊匹配 (Fuse)"]
MemSearch --> Mixed["mixed\nexact → regex → fuzzy"]
Exact --> Rerank["SemanticSearchService.rank\n语义重排"]
Regex --> Rerank
Fuzzy --> Rerank
Mixed --> Rerank
Rerank --> Results["[=] Ranked Results\n排序结果"]五种搜索模式:
| Mode | Description (EN) | 说明 (CN) |
|---|---|---|
exact |
Exact substring match | 精确子串匹配 |
fuzzy |
Fuse-style subsequence matching | Fuse 风格模糊子序列匹配 |
regex |
Regular expression | 正则表达式 |
mixed |
exact → regex → fuzzy chain | exact → regex → fuzzy 链式调用 |
semantic |
mixed + semantic reranking | mixed 基础上叠加语义重排 |
第一层:FTS5 全文检索 + trigram CJK 支持
which means Chinese, Japanese, and Korean text is treated as one giant token. Searching for 剪贴板 inside 这是一个剪贴板管理器 returns nothing.
SQLite 的 FTS5 是 Deck 搜索的主力。但 FTS5 的默认分词器按空白切分——中日韩文本会被当成一个巨大的 token。在 这是一个剪贴板管理器 中搜索 剪贴板 会一无所获。
解决方案:trigram 分词器。它不按词边界切分,而是用 3 字符滑窗切过文本,生成重叠的 token。剪贴板管理 变成 剪贴板、贴板管、板管理——子串搜索自然而然就行了。
func createFTS5Table(forceRecreate: Bool, preferTrigram: Bool) {
let tokenizer = preferTrigram && isFTSTrigramAvailable()
? "tokenize='trigram'"
: "tokenize='unicode61'"
let sql = """
CREATE VIRTUAL TABLE IF NOT EXISTS ClipboardHistory_fts
USING fts5(search_text, content='ClipboardHistory',
content_rowid='id', \(tokenizer))
"""
// ...
}启动时,ensureFTSTrigramIfAvailable() 检查当前 SQLite 是否支持 trigram。如果支持但现有表用的是 unicode61,则自动透明地重建——无需用户操作。
查询构建
查询构建器会适配当前分词器:
func buildFTSQuery(from keyword: String, useTrigram: Bool) -> String? {
let terms = keyword.components(separatedBy: .whitespaces)
.filter { !$0.isEmpty }
let processed: [String] = terms.compactMap { term in
if useTrigram {
guard term.count >= 3 else { return nil } // trigram minimum
return "\"\(term)\"" // exact phrase
} else {
return "\"\(term)\"*" // prefix match
}
}
return processed.joined(separator: " OR ")
}两种模式的关键区别:
| Trigram | Unicode61 | |
|---|---|---|
| Min query length | 3 chars | 1 char |
| Match style | Exact phrase ("term") |
Prefix match ("term"*) |
| CJK support | Native | Broken |
| Index size | ~3× larger | Compact |
结果按 BM25 排序——和 Elasticsearch、Lucene 背后相同的算法——开箱即用地提供相关性排序。
CJK 检测
Deck 通过检测 CJK 字符来决定回退策略:
| Range | Script |
|---|---|
U+4E00–U+9FFF |
CJK Unified Ideographs / CJK 统一汉字 |
U+3040–U+309F |
Hiragana / 平假名 |
U+30A0–U+30FF |
Katakana / 片假名 |
U+AC00–U+D7AF |
Hangul Syllables / 韩文音节 |
如果 trigram 不可用且查询包含 CJK 字符,Deck 回退到 SQL LIKE(数据库级),或者作为最后手段,使用内存中的字符串匹配。结果可能慢一些,但用户一定能搜到东西。
第二层:Bitap 风格模糊匹配(FuseSearch)
FTS5 快但死板——用户把"screenshot"打成"screnshot"时它帮不上忙。这就是 FuseSearch 登场的地方。
算法分两个阶段:
- 精确扫描 —
O(n)子串搜索。命中则直接返回满分。 - 模糊匹配 — 逐字符扫描文本,贪心地按顺序匹配 pattern 的每个字符。记录位置和间隔。
评分
评分公式(分数越低越好,0 = 完美匹配):
consecutiveBonus = 1.0 - (totalDistance / denominator)
startBonus = 1.0 - (firstMatchIndex / denominator)
coverageRatio = patternLength / denominator
score = 1.0 - (consecutiveBonus × 0.5 + startBonus × 0.3 + coverageRatio × 0.2)三个信号,按重要性加权:
| Signal | Weight | Intuition (EN) | 直觉 (CN) |
|---|---|---|---|
| Consecutive bonus | 50% | Reward matches that are close together | 奖励紧密聚集的匹配 |
| Start bonus | 30% | Reward matches near the beginning | 奖励靠近开头的匹配 |
| Coverage ratio | 20% | Reward longer pattern coverage | 奖励更长的模式覆盖 |
安全边界
模糊搜索天然开销大,Deck 设了硬性限制:
| Constant | Value | Purpose (EN) | 用途 (CN) |
|---|---|---|---|
threshold |
0.6 | Discard matches worse than this | 丢弃低于此分数的匹配 |
maxPatternLength |
32 | Ignore absurdly long patterns | 忽略过长的搜索词 |
fuzzySearchLimit |
5000 | Skip entries longer than this | 跳过超长文本 |
在 mixed 搜索模式中,模糊搜索是最后手段——链条按 exact → regex → fuzzy 顺序执行,只有前两步空手而归时才触发模糊搜索。
第三层:基于 NLEmbedding 的语义向量搜索
这一层让搜索系统超越了关键词匹配。利用 Apple 内置 Natural Language 框架中的 NLEmbedding,Deck 完全离线生成句向量——不需要 API 调用,不需要下载模型,没有隐私顾虑。
双语嵌入
func embedding(for text: String) -> [Double]? {
let language: NLLanguage = text.containsChineseCharacters
? .simplifiedChinese
: .english
guard let model = NLEmbedding.sentenceEmbedding(for: language) else {
return nil
}
let truncated = String(text.prefix(maxSemanticTextLength)) // 512 chars
return model.vector(for: truncated)
}Deck 根据文本是否包含中文字符动态选择嵌入模型。两个模型,一个接口——调用方完全不需要考虑语言。
加速相似度计算
余弦相似度使用 Apple Accelerate 框架的 vDSP_mmul 计算——硬件优化的矩阵乘法,运行在 CPU 的向量单元上。
为了避免与数千条记录计算相似度时出现内存峰值,计算按分块进行:
let matrixBlockSize = 256
for blockStart in stride(from: 0, to: candidates.count, by: matrixBlockSize) {
let blockEnd = min(blockStart + matrixBlockSize, candidates.count)
// vDSP_mmul on this block only
// ...
}动态阈值
短查询产生的嵌入噪声更大,所以 Deck 动态调整相似度阈值:
| Query length | Min score | Rationale (EN) | 原因 (CN) |
|---|---|---|---|
| ≤ 2 chars | 0.18 | Short queries are vague — be strict | 短查询含义模糊——需严格过滤 |
| ≤ 4 chars | 0.12 | Moderate confidence | 中等置信度 |
| > 4 chars | 0.08 | Longer queries are more precise | 长查询更精确——可放宽阈值 |
资源管理
嵌入模型常驻内存开销很大。Deck 采用懒加载 + 自动释放模式:
| Parameter | Value | Purpose (EN) | 用途 (CN) |
|---|---|---|---|
cache.countLimit |
400 | Max cached vectors | 最大向量缓存数 |
embeddingReleaseDelay |
30s | Release model after idle | 空闲后释放模型 |
maxSemanticTextLength |
512 | Truncate before embedding | 嵌入前截断文本 |
matrixBlockSize |
256 | Block size for batch similarity | 批量相似度计算的分块大小 |
EmbeddingBox 结构体同时缓存向量和预计算的 L2 范数,避免每次比较时重复归一化。
第四层:sqlite-vec — 持久化向量索引
当历史记录增长到数十万条时,内存中的余弦相似度计算无法扩展。Deck 将向量搜索下推到 sqlite-vec——一个为 SQLite 添加向量索引能力的扩展,以虚拟表形式工作。
加载策略
// 1. Try static linking first / 优先静态链接
if sqlite3_vec_init != nil {
sqlite3_vec_init(db, nil, nil)
}
// 2. Fall back to dynamic loading / 回退到动态加载
else {
let candidates = ["vec0", "vec0.dylib", "sqlite-vec", "sqlite-vec.dylib"]
for name in candidates {
if sqlite3_load_extension(db, name, nil, nil) == SQLITE_OK { break }
}
}虚拟表与查询
-- Create the vector index / 创建向量索引
CREATE VIRTUAL TABLE ClipboardHistory_vec
USING vec0(embedding float[512]);
-- Search by vector similarity / 按向量相似度搜索
SELECT rowid, distance
FROM ClipboardHistory_vec
WHERE embedding MATCH ? -- L2-normalized JSON array
ORDER BY distance
LIMIT ?;数据流转过程:
NLEmbedding → [Double] → L2 normalize → JSON array
→ INSERT INTO ClipboardHistory_embedding
→ updateVecIndex() syncs to vec virtual table
→ searchVecCandidates() returns rowid + distance这让 Deck 即使面对大量历史记录也能实现亚毫秒级向量搜索——索引存在磁盘上,按需加载,与 SQLite 的查询规划器无缝集成。
第五层:斜杠规则过滤器 — Unicode 私有区编码
高级用户需要结构化过滤:"给我看上周从 Figma 复制的图片"。Deck 通过斜杠命令支持——/app:Figma /type:image /date:7d。底层实现用了一个值得说说的 Unicode 技巧。
私有区字符技巧
当用户输入 /app: 时,搜索栏将其替换为一个彩色胶囊标签(通过 NSTextAttachment)。在底层,这个胶囊包含一个 Unicode 私有区(PUA) 字符:
enum SearchRuleToken {
static let app: Character = "\u{E000}" // U+E000
static let date: Character = "\u{E001}" // U+E001
static let type: Character = "\u{E002}" // U+E002
static let lang: Character = "\u{E003}" // U+E003
static let size: Character = "\u{E004}" // U+E004
static let marker: Character = "\u{2063}" // Invisible separator / 不可见分隔符
}为什么用 PUA?因为这些码位保证永远不会出现在正常用户文本中——不会意外冲突,不需要转义。不可见分隔符 U+2063 标记由斜杠触发插入的 token,让解析器分清哪些字符是过滤 token,哪些是用户输入。
过滤维度
| Filter | Example | Description (EN) | 说明 (CN) |
|---|---|---|---|
/app: |
/app:Xcode |
Source application | 来源应用 |
/date: |
/date:7d |
Time range | 时间范围 |
/type: |
/type:image |
Content type | 内容类型 |
/lang: |
/lang:swift |
Programming language | 编程语言 |
/size: |
/size:>1kb |
Content size | 内容大小 |
每种过滤器都支持排除模式:/-app:Safari 表示"排除来自 Safari 的条目"。
解析器 SearchRuleParser.parse 扫描富文本中的 PUA 字符,提取出结构化的 ParsedSearchQuery(keyword, ruleFilters),然后 SearchRuleFilters.apply(to:) 在内存中过滤候选集。
优雅降级:安全模式下的搜索
Deck 有一个安全模式,其中 search_text 使用 AES-GCM 加密。这意味着 FTS5 索引失效——你无法对密文做全文检索。
搜索系统优雅降级:
| Feature | Normal Mode | Safe Mode |
|---|---|---|
| FTS5 | Active | Disabled |
| Vector index | Active | Disabled (vecIndexEnabled = false) |
| Search method | Multi-layer pipeline | searchWithLike (decrypt → match in memory) |
| Scan limit | Unlimited | max(5000, limit × 200), capped at 20,000 |
| Text cache | N/A | searchTextCache — 300 decrypted entries |
| 能力 | 正常模式 | 安全模式 |
|---|---|---|
| FTS5 | 活跃 | 禁用 |
| 向量索引 | 活跃 | 禁用 (vecIndexEnabled = false) |
| 搜索方式 | 多层管线 | searchWithLike(解密 → 内存匹配) |
| 扫描上限 | 无限制 | max(5000, limit × 200),封顶 20,000 |
| 文本缓存 | N/A | searchTextCache — 缓存 300 条已解密文本 |
安全性和可搜索性天然矛盾。Deck 让这个取舍清晰可见:安全模式以搜索速度为代价保护数据,但绝不以搜索可用性为代价。
串起来
用户在搜索栏输入 screenshot /app:Xcode 时,整个流程是这样的:
sequenceDiagram
participant U as User / 用户
participant SB as Search Bar / 搜索栏
participant P as SearchRuleParser / 规则解析器
participant DB as SQLite (FTS5 + vec)
participant RF as SearchRuleFilters / 规则过滤
participant SS as SearchService / 搜索服务
participant SEM as SemanticSearchService / 语义服务
U->>SB: Type "screenshot /app:Xcode"
SB->>SB: Replace "/app:Xcode" with PUA capsule
SB->>P: Rich text with PUA tokens
P->>P: Extract keyword="screenshot", filter={app: "Xcode"}
P->>DB: FTS5 query: "screenshot"* (BM25 ranked)
DB-->>P: Candidate rowids + scores
P->>DB: sqlite-vec MATCH (embedding of "screenshot")
DB-->>P: Vector candidates + distances
P->>RF: Merge candidates → apply app=Xcode filter
RF-->>SS: Filtered candidate set
SS->>SS: mixed mode: exact → regex → fuzzy
SS->>SEM: Semantic rerank on top results
SEM-->>U: Final ranked results / 最终排序结果整条管线——FTS5、sqlite-vec、内存模糊匹配、语义重排,加上斜杠规则过滤——在 MacBook Air 上 100ms 内跑完。
回顾
没有哪种单一搜索技术能覆盖所有情况。FTS5 处理大多数查询很好用,但遇到拼写错误就不行,也做不了语义匹配。模糊搜索能容忍拼错,但在长文本上噪声很大。语义搜索能理解意图,但吃内存和 CPU。把它们堆在一起、让每层补另一层的短板,比试图造一个完美的搜索算法更现实。
CJK 支持是我们很早就做的决定。FTS5 的默认分词器按空白切分,中日韩文本基本上搜不了。trigram 分词解决了这个问题,代价是索引体积大约 3 倍——我们觉得这个取舍可以接受。
资源管理这块比预想的迭代了更多次。剪贴板管理器全天运行,嵌入模型又不小。我们经过了几轮调优才定下现在的模式:懒加载模型、最多缓存 400 个向量、空闲 30 秒后释放模型、矩阵乘法按 256 行分块执行以保持内存平稳。
本文描述的所有搜索能力都在用户的 Mac 上本地运行。没有数据离开设备,没有云端 API 调用。这是一开始就定下的约束,不是后来加的卖点。
Deck 是一款一切留在本地的 macOS 剪贴板管理器。了解更多:deckclip.app。