跳至主要內容

標題用 FTS5;篩選勝出時走表掃描

閱讀時間 7 分鐘 作者 NT²

本機搜尋不是單一查詢形狀。自由文字需要標題與搜尋文字的全文索引。 類別、垃圾桶、封存等篩選需要一般的表條件。產品會切換策略, 讓介面不必把整個保管箱塞進記憶體,也能感覺可搜尋。

標題用 FTS5;篩選勝出時走表掃描

主張很明確:自由文字搜尋使用 SQLite FTS5 處理標題與搜尋文字;當使用者只依類別、生命週期或類似條件篩選時,查詢留在一般資料表索引上。 兩條路徑都回傳分頁的輕量列表列。兩條路徑都不會把保管箱載入 Svelte state。

這聽起來像規劃器裡的小細節。但它正是保管箱在一萬筆項目時仍能保持回應,與每一次按鍵都悄悄變成用 JavaScript 走完整個閣樓——兩者的差別。

限制:一種搜尋習慣無法服務每種列表手勢

人們使用保管箱列表時不是單一動詞。他們會打開「憑證」、瀏覽最近更新、跳進已封存項目、清掉垃圾桶篩選,或輸入網站名稱的前三個字元。這些手勢在介面上很像。對資料庫來說,它們不是同一個問題。

自由文字是找針問題。 使用者要的是標題或投影搜尋文字符合 token 的列——常常是前綴比對,也常常跨過不像英文那樣以空白斷詞的語言。在 UI 層掃描每個標題是天真的答案。在保管箱變大、主執行緒忙碌、已解密 payload 又「以防萬一」搭便車之前,它都能用。

類別與生命週期是集合問題。「顯示作用中的憑證」不是模糊標題比對。它是對 schema 已為列表工作建立索引的欄位做謂詞。把這件事塞進全文引擎,只增加機制、買不到相關性。它也會混淆心智模型:本該精確的篩選,感覺卻像搜尋。

無論哪一種情況,把閣樓灌進記憶體都會因同一個理由失敗。 若解鎖或篩選變更意味著「載入每一筆,再在記憶體裡篩」,記憶體就會隨保管箱大小成長,響應式會重走 SQL 早已完成的工作,解鎖延遲也會與歷史長度成正比。本系列稍早的文章已經拒絕把這套架構用於列表本身。搜尋不是例外,不能因此再複製一份閣樓。

伺服器端明文搜尋不在選項內。 零知識保管箱不會上傳標題,好讓邊緣索引答得比較快。本機搜尋必須在裝置上、離線時仍然好用,而密封的 payload 仍然密封。

因此產品需要本機策略切換:用符合問題的索引、結果保持分頁,並且永遠不要讓瀏覽器分頁假裝自己是資料庫。

設計:文字用 FTS5 比對;篩選留在表原生路徑

NT² 把可搜尋的列表中繼資料放在每個保管箱的 SQLite 資料庫裡。標題依設計是明文列表欄位——足以導覽,而不必解密項目 payload。在項目表旁邊,有一份專用的 FTS5 索引涵蓋用於自由文字比對的標題與搜尋文字。當標題、標籤或投影欄位文字變更時,寫入路徑會同步更新該索引。

列表查詢路徑會依目前篩選集合組出單一 WHERE 子句,再以 LIMIT / OFFSET 分頁(預設每頁五十列)。策略分叉就在這個子句建構器裡:

  1. 當使用者輸入了文字查詢, 子句會包含對搜尋索引的 FTS5 MATCH,token 依作用中的語系斷詞,並以字首條件連接。命中結果再對回項目 rowid。類別、標籤、旅行模式與生命週期謂詞仍套用在同一個分頁查詢上,因此在「憑證」裡打字不會漏出其他類別。
  2. 當沒有自由文字查詢, FTS 分支保持關閉。查詢就是對項目表與相關篩選表的一般 SQL:類別 slug、封存與垃圾桶生命週期、標籤,以及類似條件。這些欄位上的索引負責縮小範圍。產品意義上的「表掃描」是指「沒有 FTS MATCH」——不是「永遠不靠索引讀完整個堆」。
  3. 計數使用同一個 WHERE。 類別徽章與「有多少筆符合?」來自聚合 SQL,而不是對已灌入記憶體的宇宙做 array.length
  4. UI state 仍然只持有一個視窗。 經過防抖的搜尋與篩選變更會重設或軟重新整理 listRows。虛擬捲動再要下一頁。開啟項目是另一條解密路徑。搜尋從不需要為了替標題打分而解密整個保管箱。
flowchart TD
  Filters[列表篩選 + 可選文字]
  Decide{有自由文字?}
  FTS[FTS5 MATCH 標題 / 搜尋文字]
  Table[索引表謂詞]
  Page[計數 + 一頁列表列]
  UI[虛擬列表視窗]

  Filters --> Decide
  Decide -->|是| FTS
  Decide -->|否| Table
  FTS --> Page
  Table --> Page
  Page --> UI

有兩個細節關乎誠實。

FTS 是用來找,不是用來擁有。 一次命中回傳識別碼與輕量列表欄位。這不代表該項目的內容金鑰已被解開,也不代表每一筆符合的列都同時常駐記憶體。

篩選可以組合;它們不會發明第二套列表架構。 標籤交集、僅封存檢視與旅行模式限制仍然是 SQL。有文字時,FTS 路徑是加性的。清空搜尋框就回到表原生路徑,不必改動分頁或虛擬化的運作方式。

這與分頁列表同一套紀律:耐用的閣樓留在 SQLite;互動表面提出精確問題,並維持有界的答案集合。

取捨:兩種查詢形狀,一份必須維護的索引

永遠走 FTS 的設計比較好解釋。永遠在 JavaScript 裡掃描的設計也一樣。兩者在規模變大時都是更差的產品。

我們為 FTS 索引付成本。 每一次標題或搜尋文字變更都要更新全文側。Schema 遷移與語系感知斷詞增加程式碼。中日韓等文字需要空白斷詞無法假裝的分詞。字首 MATCH 查詢需要謹慎跳脫,讓使用者輸入維持為資料,而不是查詢語法。

我們為策略分支付成本。 貢獻者必須擴充共用的 WHERE 建構器,而不是在 UI 裡發明一次性篩選。樂觀列表修補必須近似相同規則——或接受僅靠標籤名稱命中的結果可能要等重新載入。防抖存在,是因為每一次按鍵都是真實的資料庫問題,而不是對「已經什麼都有」的快取做免費篩選。

我們拒絕「一種萬能掃描」的幻覺。 工程師有時偏好對標題做 LIKE %q%,因為它好懂。在大型本機保管箱上,反覆的前導萬用字元掃描與主執行緒後備路徑,正是卡頓回來的方式。FTS5 存在,是為了讓標題搜尋維持為索引問題。表謂詞存在,是為了讓「顯示這個類別」維持為集合問題。

我們換回的是可預期的成本。輸入標題片段會碰全文索引並回傳一頁。不帶文字切換類別則完全跳過 FTS。記憶體與視窗相關,而不是與閣樓相關。離線仍然可用,因為索引就住在裝置上保管箱檔案旁邊。

還有一層產品誠實的取捨。我們不宣稱雲端可以搜尋你的明文標題。我們也不宣稱搜尋會解密筆記,好在每一筆 payload 的每個欄位裡找埋藏的片語。列表搜尋圍繞 schema 為導覽而索引的標題、標籤與投影搜尋文字。開啟紀錄才是完整 payload 工作的時刻。這條邊界讓零知識同步不會變成幻想中的搜尋設備。

我們拒絕什麼

架構在拒絕寫清楚時更清楚。

我們拒絕靠把每個標題載入 Svelte state 來搜尋。 若需要跑自由文字,就在 SQLite 透過 FTS5 跑,並回傳分頁。介面不會變成對全表快取的記憶體內搜尋引擎。

我們拒絕把 FTS 硬套在純篩選手勢上。 類別、生命週期與類似的精確條件,不需要全文 MATCH 才感覺正確。當篩選已經勝出,就留在表謂詞上。

我們拒絕把備份列舉當成搜尋路徑。 走完每一筆項目是匯出與類似過渡工作的批次工作。它不是解鎖後的列表回答「找 Stripe」的方式。

我們拒絕為了驅動捲動列的搜尋框而解密 payload。 標題與已索引的搜尋文字足以導覽。密文在使用者開啟紀錄前保持密封。

我們拒絕伺服器端明文標題索引。 可選同步可以移動密文。它不會因此在邊緣換來一罐可搜尋的秘密蜜罐。

我們拒絕無界的結果灌入。 即使寬鬆查詢符合數千列,仍然分頁。感覺「可搜尋」不需要一次把每個命中都放進響應式 state。

這些拒絕讓本機優先不會變成「分頁裡什麼都熱」。裝置擁有資料庫。查詢層挑選正確的索引。介面維持一個視窗。

向 SQLite 問對的問題

大型保管箱要保持好用,搜尋與篩選就必須誠實面對自己是什麼。自由文字是對標題與搜尋文字的 FTS5。精確篩選是一般索引 SQL。兩者都把輕量分頁送進虛擬列表。兩者都不會把閣樓運進 RAM。

這與把保管箱 SQLite 放在真實的瀏覽器檔案儲存、拒絕為列表做全表 Svelte 載入,以及為一萬筆項目設計卻不讓解鎖感覺像匯入試算表——是同一套技術棧故事。儲存持有結構。查詢選擇策略。介面拒絕變成第二個、更弱的資料庫。

關於規模化的產品感受,請讀一萬個項目,仍然是保管箱。本篇上一篇:永遠不要把整個保管箱載入 Svelte state。系列稍早:為什麼選擇 PWA 本地優先、零伺服器的 Vault為什麼保管箱 SQLite 放在 OPFS,而非 IndexedDB

若這種模型符合你希望私人搜尋在裝置上運作的方式,可以試試 NT² Vault,或到 nt2.me 了解更多。

最後更新 2026-08-22

相關故事