PostgreSQL 7 — Full-text search & tìm kiếm mờ

13 thg 7, 2026 2 lượt xem
#postgresql
#sql
#tsvector
#gin
#full-text-search
#pg-trgm

PostgreSQL 7 — Full-text search & tìm kiếm mờ

Gần như mọi ứng dụng đến một lúc nào đó cũng cần một ô tìm kiếm: tìm bài viết theo từ khoá, tìm sản phẩm theo mô tả, tìm ticket theo nội dung. Phản xạ đầu tiên của nhiều người là WHERE content LIKE '%postgres%'. Nó chạy — nhưng nó chậm và thô: LIKE '%...%' không dùng được B-tree bình thường nên phải quét tuần tự toàn bảng, và nó so khớp chuỗi ký tự thô chứ không hiểu ngôn ngữ. Gõ "running" sẽ không khớp "run", gõ "PostgreSQL" viết hoa sẽ trượt "postgresql", và không có khái niệm "kết quả nào liên quan hơn".

PostgreSQL có sẵn một máy tìm kiếm văn bản (full-text search, FTS) đầy đủ ngay trong nhân — không cần cài thêm hệ thống. Bài này đi qua toàn bộ: cách văn bản được biến thành tsvector, cách viết truy vấn tsquery, cách xếp hạng độ liên quan, cách đánh index GIN cho nhanh, giới hạn với tiếng Việt, tìm kiếm mờ (fuzzy) với pg_trgm, và câu hỏi quan trọng nhất: khi nào Postgres là đủ, khi nào bạn thật sự cần Elasticsearch. Nên nắm trước bài Kiểu dữ liệu (vì tsvector/tsquery là kiểu dữ liệu thật sự) và bài Extension (vì pg_trgm, unaccent là extension).

Ý tưởng cốt lõi: tách token và chuẩn hoá

FTS không so khớp chuỗi thô. Nó biến đổi cả văn bản lẫn truy vấn về một dạng chuẩn rồi mới so. Có hai kiểu dữ liệu trung tâm:

  • tsvector — văn bản đã được tách thành token (lexeme), loại stop words (từ dừng như "the", "a", "is"), stemming (đưa về gốc từ: "foxes" → "fox", "jumped" → "jump"), và ghi kèm vị trí của mỗi từ. Đây là "văn bản đã tiền xử lý" để tìm kiếm.
  • tsquery — biểu thức truy vấn đã chuẩn hoá, có toán tử logic: & (AND), | (OR), ! (NOT), <-> (theo sau ngay — phrase).

Toán tử match là @@: tsvector @@ tsquery trả true nếu văn bản khớp truy vấn.

▶ Chạy được trong SQL Builder

SELECT to_tsvector('english','the quick brown foxes jumped');

Kết quả là một tsvector: 'brown':3 'fox':4 'jump':5 'quick':2. Chú ý bốn điều quan trọng: từ "the" biến mất (stop word tiếng Anh), "foxes" thành fox và "jumped" thành jump (stemming), tất cả về chữ thường, và mỗi lexeme kèm số vị trí trong câu. Chính nhờ chuẩn hoá này mà tìm "fox" khớp "foxes", tìm "jump" khớp "jumped".

Phía truy vấn cũng được chuẩn hoá y hệt để hai bên "nói cùng ngôn ngữ":

▶ Chạy được trong SQL Builder

SELECT to_tsquery('english','jumping & foxes');

Trả về 'jump' & 'fox' — chính là những lexeme sẽ đem so với tsvector ở trên, nên phép match sẽ khớp dù văn bản gốc viết "jumped" và "foxes".

Toán tử match @@ — trái tim của FTS

Ghép hai vế lại là ra câu tìm kiếm hoàn chỉnh:

▶ Chạy được trong SQL Builder

SELECT to_tsvector('english','PostgreSQL full text search') @@ to_tsquery('english','postgres & search');

Trả true. Vì sao? to_tsvector cho ra 'full' 'postgresql' 'search' 'text', còn to_tsquery('postgres') — do stemmer tiếng Anh — cũng đưa "postgresql"/"postgres" về cùng gốc, nên postgres & search khớp. Đây là điểm khác biệt cốt lõi so với LIKE: nó hiểu từ, không phải ký tự.

Thử toán tử NOT và phrase:

▶ Chạy được trong SQL Builder

SELECT to_tsvector('english','the quick brown fox') @@ to_tsquery('english','quick & !cat');

Trả true (có "quick", không có "cat"). Còn <-> là toán tử phrase (đứng liền nhau đúng thứ tự): fox <-> jumped chỉ khớp khi "fox" đứng ngay trước "jump" trong văn bản — cực hữu ích để tìm cụm từ chính xác thay vì hai từ rời rạc nằm cách xa nhau.

Các hàm xây tsquery: chọn đúng cửa ngõ

to_tsquery mạnh nhưng "khó tính" — nó yêu cầu cú pháp chuẩn (&, |), và ném lỗi nếu bạn đưa vào chuỗi người dùng gõ tự do có dấu cách hay ký tự lạ. Trong thực tế, bạn hiếm khi đưa input thô của người dùng vào to_tsquery. Có ba hàm "an toàn hơn":

  • plainto_tsquery — nhận văn bản thô, tự nối các từ bằng &. "quick fox" → 'quick' & 'fox'.
  • phraseto_tsquery — như trên nhưng nối bằng <->, tức tìm đúng cụm từ liền nhau.
  • websearch_to_tsquery — cú pháp kiểu Google, thân thiện nhất với ô tìm kiếm cho người dùng cuối (có từ PostgreSQL 11): dấu ngoặc kép "..." cho cụm từ, dấu trừ - để loại trừ, từ khoá or cho lựa chọn. Nó không bao giờ ném lỗi với input xấu — rất quan trọng khi nhận chuỗi tự do.

▶ Chạy được trong SQL Builder

SELECT plainto_tsquery('english','quick fox');

Trả 'quick' & 'fox'. So sánh với websearch_to_tsquery('english','"quick fox" -cat') sẽ cho 'quick' <-> 'fox' & !'cat' — hiểu được cả cụm từ trong ngoặc kép lẫn loại trừ. Khuyến nghị thực tế: dùng websearch_to_tsquery cho ô tìm kiếm người dùng, vì nó dung thứ input bẩn và cú pháp quen thuộc.

Xếp hạng độ liên quan: ts_rank & ts_rank_cd

Match @@ chỉ trả true/false. Nhưng tìm kiếm thật cần sắp theo độ liên quan — kết quả nào "trúng" nhất lên đầu. Đó là việc của ts_rankts_rank_cd.

▶ Chạy được trong SQL Builder

SELECT ts_rank(to_tsvector('english','a b c a'), to_tsquery('english','a'));

Trả một số thực (điểm liên quan). ts_rank tính điểm dựa trên tần suất lexeme khớp — từ khoá xuất hiện càng nhiều, điểm càng cao (ở đây "a" xuất hiện 2 lần). Còn ts_rank_cd ("cover density") thưởng thêm cho độ gần nhau của các từ khớp: hai từ khoá đứng sát nhau được điểm cao hơn khi chúng nằm rải rác — mô phỏng trực giác rằng "New York" trong một câu thì liên quan hơn "New" ở đầu và "York" ở cuối bài.

Trong truy vấn thật, mẫu quen thuộc là:

SELECT id, title,
       ts_rank(search_vec, websearch_to_tsquery('english', 'postgres index')) AS rank
FROM articles
WHERE search_vec @@ websearch_to_tsquery('english', 'postgres index')
ORDER BY rank DESC
LIMIT 20;

setweight: không phải trường nào cũng quan trọng như nhau

Khớp từ khoá trong tiêu đề nên "nặng" hơn khớp trong thân bài. setweight gán nhãn A/B/C/D (A nặng nhất) cho từng phần, và ts_rank sẽ nhân trọng số tương ứng:

UPDATE articles SET search_vec =
    setweight(to_tsvector('english', coalesce(title,'')),   'A') ||
    setweight(to_tsvector('english', coalesce(summary,'')), 'B') ||
    setweight(to_tsvector('english', coalesce(body,'')),    'C');

Toán tử || nối các tsvector lại. Nhờ vậy một bài có từ khoá ở tiêu đề luôn xếp trên bài chỉ nhắc thoáng qua trong thân — đây là bước tinh chỉnh relevance quan trọng nhất trong FTS thực tế.

ts_headline: tô đậm đoạn khớp

Kết quả tìm kiếm đẹp thường kèm đoạn trích có tô đậm (snippet). ts_headline làm việc đó:

▶ Chạy được trong SQL Builder

SELECT ts_headline('english','the quick brown fox jumped over the lazy dog', to_tsquery('english','fox'));

Trả về đoạn văn bản với <b>fox</b> được bao thẻ — cắm thẳng vào giao diện là có highlight. Lưu ý ts_headline chậm (phải xử lý lại văn bản gốc) nên chỉ chạy trên số ít dòng cuối cùng của trang, sau khi đã lọc và phân trang.

Index GIN: từ chậm thành nhanh

Không có index, mỗi truy vấn FTS phải gọi to_tsvector trên từng dòng rồi so — đó là seq scan tốn kém. GIN (Generalized Inverted Index) là index đảo ngược: nó lưu ánh xạ mỗi lexeme → danh sách các dòng chứa lexeme đó, đúng như cách một máy tìm kiếm hoạt động. Truy vấn "postgres" chỉ cần tra thẳng lexeme postgres là ra ngay danh sách dòng, không cần đọc toàn bảng.

Cách làm hiện đại nhất (PostgreSQL 12+) là dùng cột sinh (GENERATED ALWAYS AS) để tsvector luôn tự đồng bộ với văn bản gốc, rồi đánh GIN lên nó:

ALTER TABLE articles
  ADD COLUMN search_vec tsvector
  GENERATED ALWAYS AS (to_tsvector('english', coalesce(title,'') || ' ' || coalesce(body,''))) STORED;

CREATE INDEX idx_articles_search ON articles USING GIN (search_vec);

(Minh hoạ — CREATE INDEX/ALTER TABLE không chạy trong sandbox.) Cột sinh có ưu điểm lớn: bạn không thể quên cập nhật nó, và không cần trigger thủ công. Cách cũ hơn là index biểu thức trực tiếp: CREATE INDEX ... USING GIN (to_tsvector('english', body)) — nhưng khi đó truy vấn phải lặp lại chính xác biểu thức đó thì planner mới dùng được index, dễ sai. Cột sinh gọn và an toàn hơn.

Đánh đổi: GIN làm chậm INSERT/UPDATE (mỗi lần ghi phải cập nhật index đảo ngược) và tốn dung lượng. Với bảng ghi nhiều, cân nhắc tham số fastupdate của GIN. Chi tiết về cân bằng đọc/ghi của index xem series Chỉ mục.

Cấu hình ngôn ngữ, dictionary và thực tế tiếng Việt

Tham số đầu tiên của to_tsvectorcấu hình tìm kiếm văn bản (text search configuration), quyết định stop words và stemmer nào được dùng. Hai giá trị hay gặp:

  • 'english' (và các ngôn ngữ khác như 'french', 'german'...) — có stemmer và stop words đầy đủ cho ngôn ngữ đó.
  • 'simple' — cấu hình "trơn": chỉ tách token và hạ chữ thường, không stemming, không loại stop word. Mỗi từ giữ nguyên dạng.

▶ Chạy được trong SQL Builder

SELECT to_tsvector('simple','the quick brown foxes jumped');

Khác hẳn ví dụ 'english' đầu bài: ở đây "the" vẫn còn, "foxes"/"jumped" giữ nguyên không về gốc, mỗi từ một lexeme. Đó chính là điểm mấu chốt cho tiếng Việt.

Thực tế với tiếng Việt: PostgreSQL không có cấu hình dựng sẵn cho tiếng Việt — không stemmer, không từ điển, và đặc biệt máy tách token của Postgres tách theo khoảng trắng, trong khi tiếng Việt là ngôn ngữ đơn lập với nhiều từ ghép ("cơ sở dữ liệu" là một khái niệm nhưng bị tách thành ba token). Hệ quả thực tế:

  • Dùng cấu hình 'simple' cho tiếng Việt — nó không cố stem sai, chỉ tách theo khoảng trắng và hạ chữ thường. Đây là lựa chọn an toàn phổ biến nhất.
  • Vấn đề dấu: người dùng thường gõ không dấu ("co so du lieu"). Extension unaccent bỏ dấu để "Nguyễn" khớp "nguyen"; kết hợp nó vào một cấu hình tuỳ biến, hoặc lưu thêm một cột đã unaccent. (unaccentextension, xem bài Extension.)
  • Với nhu cầu tiếng Việt nghiêm túc (tách từ ghép đúng), FTS thuần Postgres là hạn chế — nhiều dự án chuyển sang tìm kiếm theo trigram (pg_trgm, mục dưới) vốn không phụ thuộc ngôn ngữ, hoặc dùng ES với analyzer tiếng Việt.

Đây là điểm cần nói thẳng: FTS của Postgres được thiết kế quanh các ngôn ngữ biến hình kiểu châu Âu; với tiếng Việt bạn chủ yếu tận dụng phần tách token + index GIN, còn stemming thì gần như không có tác dụng.

Tìm mờ (fuzzy) với pg_trgm

FTS xử lý từ, nhưng nó không cứu được lỗi gõ: gõ "postgers" sẽ không khớp "postgres". Đây là lúc pg_trgm (extension) vào cuộc. Nó băm chuỗi thành các trigram — cụm 3 ký tự liên tiếp — rồi đo độ giống nhau theo số trigram chung.

Ví dụ "cat" → các trigram {" c"," ca","cat","at "}. Hai chuỗi càng nhiều trigram chung thì similarity() càng gần 1.

SELECT similarity('postgres', 'postgers');

(Minh hoạ — pg_trgmextension, có thể chưa cài trong sandbox nên không chạy được ở đây.) Trả về một số ~0.5–0.6, đủ cao để coi là "gần giống". Toán tử % là phiên bản boolean: 'postgres' % 'postgers' trả true nếu độ giống vượt ngưỡng pg_trgm.similarity_threshold (mặc định 0.3). Ứng dụng chính:

  • Gợi ý "ý bạn là..." (did you mean): tìm từ trong danh mục giống nhất với chuỗi người dùng gõ sai.
  • Tăng tốc ILIKE '%...%': bình thường ILIKE với % hai đầu không dùng index được — nhưng GIN/GiST trigram thì . CREATE INDEX ... USING GIN (name gin_trgm_ops) khiến WHERE name ILIKE '%abc%' chạy nhanh trên bảng lớn. Đây là mẹo tăng tốc tìm kiếm con chuỗi cực kỳ hữu ích.
  • Autocomplete và fuzzy match không phụ thuộc ngôn ngữ — đặc biệt hợp tiếng Việt vì trigram không cần hiểu ngữ pháp.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_products_name_trgm ON products USING GIN (name gin_trgm_ops);
-- Truy vấn ILIKE giờ dùng được index:
SELECT name FROM products WHERE name ILIKE '%laptp%';
-- "Did you mean": sắp theo độ giống
SELECT name, similarity(name, 'postgers') AS sim
FROM products WHERE name % 'postgers' ORDER BY sim DESC LIMIT 5;

(Minh hoạ — cần extension và bảng thật, không chạy trong sandbox.)

FTS vs pg_trgm khi nào dùng cái nào: FTS tốt cho tìm theo từ khoá/ngữ nghĩa từ trên văn bản dài (bài viết, mô tả); pg_trgm tốt cho so gần đúng chuỗi ngắn (tên, mã, tiêu đề) và dung thứ lỗi gõ. Nhiều hệ thống dùng cả hai: FTS để lọc kết quả chính, pg_trgm để gợi ý sửa lỗi khi FTS không ra gì.

Use case thực tế

1. Ô tìm kiếm bài viết/sản phẩm. Thêm cột search_vec sinh từ title (weight A) + body (weight C), đánh GIN lên nó. Ô tìm kiếm nhận chuỗi tự do, đưa qua websearch_to_tsquery, lọc bằng @@, xếp bằng ts_rank, phân trang, rồi chạy ts_headline chỉ trên trang hiện tại để tô đậm. Toàn bộ nằm trong Postgres, không thêm hạ tầng — với vài trăm nghìn đến vài triệu bài, hiệu năng thừa sức.

2. Gợi ý sửa lỗi gõ. Khi FTS trả về 0 kết quả (người dùng gõ sai), fallback sang pg_trgm: SELECT title FROM articles WHERE title % :q ORDER BY similarity(title, :q) DESC LIMIT 5 để hiện "Có phải bạn muốn tìm...". Kết hợp GIN trigram thì truy vấn này vẫn nhanh.

3. Autocomplete tên khách hàng/sản phẩm có dấu lẫn không dấu: kết hợp unaccent + trigram index để "nguyen van a" khớp "Nguyễn Văn A".

So sánh: Postgres FTS vs Elasticsearch/OpenSearch

Câu hỏi kiến trúc quan trọng nhất. Postgres FTS đủ dùng khi:

  • Dữ liệu vừa phải (đến hàng triệu bản ghi văn bản), tìm kiếm là tính năng phụ chứ không phải sản phẩm chính.
  • Bạn muốn tránh thêm một hệ thống — không phải vận hành cluster riêng, không phải đồng bộ dữ liệu hai chiều, không có độ trễ giữa DB và search index (FTS luôn nhất quán tức thời với dữ liệu vì nó dữ liệu).
  • Cần tìm kiếm kết hợp với điều kiện SQL khác (JOIN, filter theo user_id, WHERE status='published') — điều này trong Postgres là tự nhiên, trong ES thì phức tạp.

Elasticsearch/OpenSearch mạnh hơn khi:

  • Quy mô lớn (hàng trăm triệu tới hàng tỷ document), cần phân tán (sharding, replication chuyên cho search) và throughput tìm kiếm rất cao.
  • Cần relevance/analyzer phong phú: synonym, fuzzy matching tinh vi, phân tích ngôn ngữ đa dạng (bao gồm analyzer tiếng Việt), aggregation/facet phức tạp, autocomplete cao cấp.
  • Tìm kiếm sản phẩm cốt lõi (như một cỗ máy tìm kiếm), đáng để trả giá vận hành thêm một hệ thống và chấp nhận đồng bộ dữ liệu (eventual consistency).

Nguyên tắc thực dụng: bắt đầu với Postgres FTS. Nó thường "đủ tốt" lâu hơn bạn nghĩ, và tránh được cả một hệ thống phân tán phải nuôi. Chỉ chuyển sang ES khi bạn đo được rằng FTS không đáp ứng — về hiệu năng, về chất lượng relevance, hoặc về quy mô.

Một hướng thứ ba đang lên: tìm kiếm ngữ nghĩa (semantic search) bằng vector embedding — tìm theo ý nghĩa chứ không theo từ khoá ("xe hơi" khớp "ô tô"). Trong Postgres, extension pgvector làm việc này, và có thể kết hợp FTS (từ khoá) với vector (ngữ nghĩa) thành tìm kiếm lai (hybrid). Chi tiết pgvector xem bài Extension.

Ghi nhớ

  • tsvector = văn bản đã tách token + chuẩn hoá (stemming, stop words, vị trí); tsquery = truy vấn có & | ! <->; match bằng @@. FTS hiểu từ, LIKE chỉ so ký tự.
  • Xây tsquery: to_tsquery (khó tính, cần cú pháp chuẩn), plainto_tsquery (nối &), phraseto_tsquery (nối <->), websearch_to_tsquery (kiểu Google, an toàn với input người dùng — dùng cái này cho ô tìm kiếm).
  • Xếp hạng bằng ts_rank (tần suất) / ts_rank_cd (thưởng độ gần); dùng setweight A/B/C/D để tiêu đề nặng hơn thân bài; ts_headline để tô đậm (chỉ chạy trên số ít dòng cuối).
  • Tăng tốc bằng GIN trên cột tsvector; ưu tiên cột sinh GENERATED ALWAYS AS ... STORED để luôn đồng bộ. GIN làm chậm ghi — cân nhắc.
  • Cấu hình 'english' có stemmer; 'simple' chỉ tách + hạ chữ thường. Tiếng Việt: không có cấu hình dựng sẵn → dùng 'simple' + unaccent, hoặc chuyển sang trigram/ES cho nhu cầu nghiêm túc.
  • pg_trgm (extension) cho tìm mờ: similarity(), toán tử %, và GIN/GiST trigram để tăng tốc ILIKE '%...%' và làm "ý bạn là...".
  • Postgres FTS đủ cho dữ liệu vừa, tìm kiếm tích hợp, tránh thêm hệ thống; ES cho quy mô lớn, relevance/analyzer phong phú, phân tán. Bắt đầu với Postgres, chuyển khi đo được là cần. Semantic search → pgvector.

Bài viết liên quan

Cách index hoạt động (B-Tree), đọc EXPLAIN, seq scan vs index scan và mẫu tối ưu truy vấn.

13 thg 7, 2026 4

Khoá, ràng buộc, quan hệ và chuẩn hoá 1NF/2NF/3NF — thiết kế lược đồ đúng từ đầu.

13 thg 7, 2026 4

Kết nhiều bảng đúng cách: các loại JOIN, bẫy thường gặp và phép hợp tập.

13 thg 7, 2026 3

Truy vấn lồng, CTE (WITH), CTE đệ quy và hàm cửa sổ — vũ khí cho phân tích nâng cao.

13 thg 7, 2026 3