返回博客
2026 年 3 月 6 日7 分钟阅读SearchSQLiteNLP

本地优先的多层搜索架构

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。剪贴板管理 变成 剪贴板贴板管板管理——子串搜索自然而然就行了。

swift
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,则自动透明地重建——无需用户操作。

查询构建

查询构建器会适配当前分词器:

swift
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 登场的地方。

算法分两个阶段:

  1. 精确扫描O(n) 子串搜索。命中则直接返回满分。
  2. 模糊匹配 — 逐字符扫描文本,贪心地按顺序匹配 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 调用,不需要下载模型,没有隐私顾虑。

双语嵌入

swift
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 的向量单元上。

为了避免与数千条记录计算相似度时出现内存峰值,计算按分块进行:

swift
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 添加向量索引能力的扩展,以虚拟表形式工作。

加载策略

swift
// 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 }
    }
}

虚拟表与查询

sql
-- 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) 字符:

swift
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