SQLite FTS5 Full-Text Search: Tìm kiếm nội dung trong app không cần server

SQLite FTS5 (Full-Text Search version 5) là công cụ tìm kiếm toàn văn bản tích hợp sẵn trong SQLite, cho phép tìm kiếm nội dung văn bản nhanh chóng mà không cần máy chủ tìm kiếm riêng biệt như Elasticsearch hay Meilisearch. Với ứng dụng desktop, mobile hoặc web nhẹ, FTS5 giải quyết bài toán tìm kiếm tức thì trên dữ liệu cục bộ với dung lượng chỉ vài MB.

Tại sao chọn SQLite FTS5 thay vì Elasticsearch?

Elasticsearch mạnh mẽ nhưng quá lớn cho nhiều dự án: cần JVM, cluster, RAM 4 GB+ chỉ để chạy. SQLite FTS5 chạy trong-process, không phụ thuộc mạng, khởi động tức thì, và tiêu thụ RAM bằng kích thước database. Phù hợp cho: ứng dụng ghi chú (notes app), email client offline, tài liệu hướng dẫn nhúng sẵn, cache tìm kiếm cho PWA.

Biểu đồ so sánh kiến trúc SQLite FTS5 vs Elasticsearch: ứng dụng gọi SQLite trực tiếp vs ứng dụng gọi API Elasticsearch qua mạng

Cơ chế hoạt động của FTS5

FTS5 xây dựng inverted index (chỉ mục đảo) từ nội dung văn bản. Mỗi token (từ) được tách bằng tokenizer, lưu vị trí xuất hiện trong từng hàng. Khi truy vấn, FTS5 tra chỉ mục thay vì quét toàn bảng — độ phức tạp O(log n) thay vì O(n).

Các tokenizer tích hợp:

  • unicode61 (mặc định): tách theo Unicode 6.1, hỗ trợ đa ngôn ngữ, bỏ dấu cách và ký tự đặc biệt
  • porter: stemming tiếng Anh (running → run)
  • ascii: chỉ tách ASCII, nhanh hơn nhưng không hỗ trợ tiếng Việt có dấu

Đối với tiếng Việt, unicode61 xử lý tốt ký tự có dấu nhưng không tách từ (Vietnamese không có dấu cách giữa từ). Cần tokenizer tùy chỉnh hoặc tiền xử lý tách từ bằng underthesea/vncorenlp trước khi insert.

Minh họa inverted index: bảng token → (doc_id, position) với ví dụ

Tạo bảng FTS5 cơ bản

CREATE VIRTUAL TABLE docs USING fts5(
    title,
    content,
    author,
    tokenize = 'unicode61'
);

Bảng ảo docs hành xử như bảng thường cho INSERT, UPDATE, DELETE, SELECT. Cột ẩn rowid làm khóa chính.

Chèn dữ liệu và tìm kiếm

-- Chèn
INSERT INTO docs(title, content, author) VALUES
('Hướng dẫn SQLite', 'SQLite là cơ sở dữ liệu nhẹ, nhúng, không cần server', 'Nguyễn Văn A'),
('FTS5 toàn văn bản', 'FTS5 cho phép tìm kiếm từ khóa trong cột content nhanh chóng', 'Trần Thị B');

-- Tìm kiếm đơn giản
SELECT title, snippet(docs, 0, '', '', '...', 32) AS excerpt
FROM docs WHERE docs MATCH 'SQLite';

-- Tìm kiếm cụm từ (phrase search)
SELECT * FROM docs WHERE docs MATCH '"tìm kiếm"';

-- Tìm kiếm tiền tố (prefix) với *
SELECT * FROM docs WHERE docs MATCH 'tìm*';

-- Tìm kiếm NEAR (gần nhau): từ A cách từ B tối đa 5 vị trí
SELECT * FROM docs WHERE docs MATCH 'nearsqlite nhanh 5';

Hàm snippet() trả về đoạn tríchハイilight từ khóa (wrapped trong ), giới hạn độ dài, thêm dấu ba chấm.

Bảng nội dung riêng (contentless) và external content

Mặc định FTS5 lưu bản sao nội dung trong bảng ảo (tốn dung lượng). Hai chế độ tối ưu:

  • contentless (content=''): không lưu nội dung, chỉ lưu chỉ mục. INSERT chỉ cập nhật index. SELECT * trả về NULL cho cột content. Phù hợp khi nội dung gốc ở bảng khác.
  • external content (content='posts'): nội dung lưu ở bảng posts thường, FTS5 chỉ giữ chỉ mục + trigger đồng bộ. SELECT * FROM fts tự join lấy content từ posts.
-- External content table
CREATE TABLE posts(id INTEGER PRIMARY KEY, title TEXT, body TEXT);

-- FTS5 index table
CREATE VIRTUAL TABLE posts_fts USING fts5(title, body, content='posts', content_rowid='id');

-- Trigger đồng bộ
CREATE TRIGGER posts_ai AFTER INSERT ON posts BEGIN
    INSERT INTO posts_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
END;

CREATE TRIGGER posts_ad AFTER DELETE ON posts BEGIN
    INSERT INTO posts_fts(posts_fts, rowid, title, body) VALUES ('delete', old.id, old.title, old.body);
END;

CREATE TRIGGER posts_au AFTER UPDATE ON posts BEGIN
    INSERT INTO posts_fts(posts_fts, rowid, title, body) VALUES ('delete', old.id, old.title, old.body);
    INSERT INTO posts_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
END;

Ranking kết quả với bm25()

FTS5 cung cấp hàm bm25() tính điểm BM25 (Okapi BM25) — thuật toán ranking chuẩn ngành thông tin truy xuất. Điểm càng thấp càng liên quan.

SELECT title, bm25(docs) AS score
FROM docs WHERE docs MATCH 'SQLite'
ORDER BY score LIMIT 10;

Có thể điều trọng số cột: bm25(docs, 1.0, 0.5, 0.2) — title quan trọng gấp 2 lần content, author ít quan trọng.

Tích hợp vào ứng dụng thực tế

Python (sqlite3 stdlib):

import sqlite3

conn = sqlite3.connect('app.db')
conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS notes USING fts5(title, body, tokenize='unicode61')")

def search_notes(query, limit=20):
    sql = """SELECT rowid, title, snippet(notes, 1, '', '', '...', 64) AS excerpt
             FROM notes WHERE notes MATCH ? ORDER BY bm25(notes) LIMIT ?"""
    return conn.execute(sql, (query, limit)).fetchall()

Swift (GRDB.swift / SQLite.swift): GRDB hỗ trợ FTS5 qua FTS5TableDefinition, tự động sinh trigger external content.

Flutter (sqflite + fts5): Plugin sqflite hỗ trợ CREATE VIRTUAL TABLE, dùng rawQuery cho MATCH.

Node.js (better-sqlite3): Hiệu năng cao, synchronous API phù hợp Electron/Tauri.

Xử lý tiếng Việt: Tách từ trước khi index

unicode61 không tách từ tiếng Việt, cần pipeline tiền xử lý:

import underthesea

def vietnamese_tokenize(text):
    return ' '.join(underthesea.word_tokenize(text, format='text').split())

# Khi insert
tokenized_title = vietnamese_tokenize(title)
tokenized_body = vietnamese_tokenize(body)
conn.execute("INSERT INTO notes(title, body) VALUES (?, ?)", (tokenized_title, tokenized_body))

# Khi query: cũng phải tokenize query
tokenized_query = vietnamese_tokenize(user_query)
conn.execute("SELECT * FROM notes WHERE notes MATCH ?", (tokenized_query,))

Lưu ý: tokenize query giống cách tokenize data — nhất quán mới khớp được.

Giới hạn và tối ưu

  • Dung lượng index: khoảng 30-50% dung lượng dữ liệu gốc. Bật PRAGMA page_size=4096, PRAGMA journal_mode=WAL.
  • Rebuild index: INSERT INTO fts(fts) VALUES('rebuild') sau khi thay đổi tokenizer hoặc sửa dữ liệu hàng loạt.
  • Ký tự đặc biệt: FTS5 coi -, +, *, :, (), "" là toán tử. Escape bằng dấu ngoặc kép: MATCH '"C++"'.
  • Tối đa 128 cột, mỗi hàng tối đa 1 GB (thực tế giới hạn bởi page size).

Khi nào KHÔNG dùng FTS5

  • Dữ liệu > 10 GB, truy cập đồng thời nhiều writer — chuyển PostgreSQL + pg_trgm hoặc Elasticsearch.
  • Cần fuzzy search (sai chính tả), autocomplete gợi ý — FTS5 chỉ exact/prefix.
  • Cần faceted search, aggregations, geo-search — ngoài phạm vi SQLite.

SQLite FTS5 là giải pháp tìm kiếm “đủ tốt” (good enough) cho 90% ứng dụng nhúng, offline-first, hoặc side-project. Không server, không config, không bill cloud — chỉ một file .db và vài dòng SQL.

Tham khảo: SQLite FTS5 Documentation | underthesea Vietnamese NLP

Tôi là một lập trình viên IOS. Code chính là IOS nhưng thỉnnh thoảng vẫn đá sang Android hoặc web. Mặc dù không quá thông thạo nhưng tôi sẽ chia sẻ những kiến thức mà mình đã tìm hiểu, áp dụng qua.

Bài viết liên quan

Golang Concurrency Patterns: Worker Pool, Pipeline, Fan-out/Fan-in thực tế

Golang nổi tiếng với mô hình concurrency “share memory by communicating” thay vì “share memory by locking”. Nhưng pattern thông thường như worker pool, pipeline, fan-out/fan-in không tự động xuất…

Xem thêm

CI/CD GitHub Actions vs GitLab CI: Công cụ nào phù hợp dự án 2025?

CI/CD GitHub Actions vs GitLab CI: Công cụ nào phù hợp dự án 2025? CI/CD (Continuous Integration / Continuous Deployment) là xương sống của DevOps hiện đại — tự động…

Xem thêm

Typst: Ngôn ngữ markup khoa học thay thế LaTeX cho bài báo và tài liệu

Typst: Ngôn ngữ markup khoa học thay thế LaTeX cho bài báo và tài liệu Typst là ngôn ngữ markup mới sinh ra năm 2021, thiết kế để thay thế…

Xem thêm
0 0 đánh giá
Article Rating
Theo dõi
Thông báo của
guest
0 Comments
Cũ nhất
Mới nhất Được bỏ phiếu nhiều nhất