雲端 Mac 上的 iOS FTS5 搜尋驗收:斷詞、查詢跳脫與索引同步

雲端 Mac 上的 iOS FTS5 搜尋驗收:斷詞、查詢跳脫與索引同步

客戶改了工單標題,清單已顯示新名稱,離線搜尋卻仍會命中舊詞。工作室輪流使用不同的雲端 Mac 打包時,很容易漏掉這類問題:專案能編譯,資料庫也能開啟,但全文索引早已與業務資料表脫節。交付前應先用固定樣本驗證搜尋契約;直接拿正式環境資料測試,可以補充觀察,但不適合作為唯一標準,因為結果難以穩定重現。

先確認實際使用的 SQLite

在雲端 Mac 的終端機執行 sqlite3 :memory: 'SELECT sqlite_version();',記錄實驗使用的版本。接著嘗試建立 FTS5 虛擬資料表;若建立失敗就停止,不要把「沒有搜尋結果」誤判為斷詞問題。

命令列工具只能驗證實驗環境。iOS 專案可能連結系統程式庫,也可能自行內附 SQLite,因此還要透過應用程式實際使用的連線,執行相同的建表陳述式。將這項檢查納入測試目標,並在目標系統上執行。純 SQL 樣本可以先在 Mac 上快速反覆測試,但不能取代行動端的執行結果。

固定斷詞與查詢語意

先決定採用「字面片語搜尋」還是「進階搜尋語法」。前者適合一般工單搜尋框,後者適合明確支援布林運算式的工具;不應在沒有提示的同一個輸入框中混用兩種行為。

unicode61 remove_diacritics 2 可以處理常見的拉丁字母變音符號,但不是通用的中文斷詞器。樣本至少要涵蓋大小寫、變音符號、連字號、引號及連續中文。不要因為英文測試通過,就宣稱中文支援任意詞語搜尋。

SQL 參數繫結與 MATCH 跳脫是兩個不同層次的處理。參數繫結可防止輸入成為 SQL 結構的一部分,卻不會停用 MATCH 自己的查詢語法。處理字面片語時,可以先將內部的雙引號加倍,再於前後加上一對雙引號:

func literalFTSQuery(_ text: String) -> String? {
    let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
    guard !trimmed.isEmpty else { return nil }
    return "\"" + trimmed.replacingOccurrences(of: "\"", with: "\"\"") + "\""
}

將回傳值繫結至 WHERE ticket_fts MATCH ?,不要拼接 SQL。這裡的「字面」仍會經過斷詞,不等於逐位元組的子字串比對;遇到空白輸入,直接回傳空結果,或產品規則約定的預設清單。

搜尋命中規則是產品契約。前綴、片語與子字串不能互相替代,更不能藉由吞掉語法錯誤來製造「零結果」。

用觸發程序同步外部內容索引

外部內容資料表適合已有業務資料表、且不想重複儲存內文的專案;不適合預期 FTS5 會自動監聽業務資料表變更的實作。content 只宣告關聯,新增、刪除與更新仍需同步。

以下範例可直接在終端機執行。所有資料只存在於記憶體中,任何一項斷言失敗,都會讓命令以非零狀態碼結束:

sqlite3 -bail :memory: <<'SQL'
CREATE TABLE ticket(id INTEGER PRIMARY KEY, title TEXT NOT NULL);
CREATE VIRTUAL TABLE ticket_fts USING fts5(
  title,
  content='ticket',
  content_rowid='id',
  tokenize='unicode61 remove_diacritics 2'
);

CREATE TRIGGER ticket_ai AFTER INSERT ON ticket BEGIN
  INSERT INTO ticket_fts(rowid, title) VALUES(new.id, new.title);
END;

CREATE TRIGGER ticket_ad AFTER DELETE ON ticket BEGIN
  INSERT INTO ticket_fts(ticket_fts, rowid, title)
    VALUES('delete', old.id, old.title);
END;

CREATE TRIGGER ticket_au AFTER UPDATE ON ticket BEGIN
  INSERT INTO ticket_fts(ticket_fts, rowid, title)
    VALUES('delete', old.id, old.title);
  INSERT INTO ticket_fts(rowid, title) VALUES(new.id, new.title);
END;

CREATE TEMP TABLE assertion(ok INTEGER NOT NULL CHECK(ok = 1));

INSERT INTO ticket VALUES(1, 'Café delivery');
INSERT INTO assertion
  SELECT count(*) = 1 FROM ticket_fts
  WHERE ticket_fts MATCH '"cafe"';

UPDATE ticket SET title = 'Invoice approved' WHERE id = 1;
INSERT INTO assertion
  SELECT count(*) = 0 FROM ticket_fts
  WHERE ticket_fts MATCH '"cafe"';
INSERT INTO assertion
  SELECT count(*) = 1 FROM ticket_fts
  WHERE ticket_fts MATCH '"invoice"';

BEGIN;
DELETE FROM ticket WHERE id = 1;
INSERT INTO assertion
  SELECT count(*) = 0 FROM ticket_fts
  WHERE ticket_fts MATCH '"invoice"';
ROLLBACK;

INSERT INTO assertion
  SELECT count(*) = 1 FROM ticket_fts
  WHERE ticket_fts MATCH '"invoice"';

DELETE FROM ticket WHERE id = 1;
INSERT INTO assertion
  SELECT count(*) = 0 FROM ticket_fts
  WHERE ticket_fts MATCH '"invoice"';
SQL

刪除索引項目時必須提供舊文字,因此更新觸發程序要先刪除舊項目,再插入新項目。業務資料變更與觸發程序位於同一筆交易中,交易回復後,業務資料表與索引應一併還原。範例執行後沒有錯誤輸出即表示通過,不必人工檢查終端機中的表格。

既有資料與排序也要驗收

若資料庫先建立業務資料表、之後才加上索引,安裝觸發程序並不會自動補齊既有記錄。遷移時,應在寫入受到控管的範圍內執行 INSERT INTO ticket_fts(ticket_fts) VALUES('rebuild');,再用遷移前的固定記錄驗證是否命中。重建索引屬於遷移作業,不應在每次開啟搜尋頁面時執行。

不要用 SELECT count(*) FROM ticket_fts 判斷索引是否完整:外部內容資料表在未使用 MATCH 時,可能直接讀取內容資料表。必須透過實際查詢建立斷言,分別驗證舊詞不再命中、新詞能夠命中。

排名相同時,應再依穩定的業務主鍵排序,例如 ORDER BY rank, rowid。分頁樣本要包含多筆同分結果;否則重複執行時順序改變,很容易被誤判為資料遺漏。

把失敗轉成交付檢查項目

每次交付都要保存 SQLite 版本、建表參數、樣本版本與失敗案例名稱,不要保存客戶實際輸入的搜尋文字。補上空字串、純標點符號、超長輸入及雙引號樣本;超過長度上限時,應依產品規則明確拒絕,不要依賴資料庫例外來兜底。

驗收時逐項確認:新建資料庫與遷移後的資料庫結果一致、改名後舊詞不再命中、刪除後沒有殘留、交易回復後結果恢復,以及中文搜尋行為符合說明。最後在 iOS 測試目標中執行同一套資料。如此一來,更換雲端 Mac 或交接客戶專案時,檢查的就是搜尋行為本身,而不是某台機器上碰巧留下的正確索引。

常見問題

Mac 的 sqlite3 能執行 FTS5,就代表 iOS App 支援嗎?

不代表。命令列工具與應用程式可能連結不同的 SQLite 函式庫,必須透過應用程式實際連線建立暫存 FTS5 表,並在目標系統執行相同樣本。

unicode61 會自動將中文句子切成詞嗎?

它不提供通用中文詞語切分。應以實際中文樣本確認產品行為;若需要詞級搜尋,必須另選斷詞方案,並讓索引與查詢採用一致的處理方式。

租用期間,配合專案時程安排

適合臨時打包、交付週增加主機,以及多客戶專案分開使用。若團隊長期滿載建置,且已有自有設備,建議先比較購買與租用的成本,再做決定。

VMSSD 提供獨享實體 Mac mini,並非虛擬機。可選擇日租、週租、月租或季租;租期到期後未續租便會停止服務。機型與節點的供應狀況,請以控制台即時顯示為準。

讀完文章,接著安排下一步

先確認專案時程與建置負載,再決定機型和租期。第一次使用的話,可以先查看下單清單。