Giao diện
Full-text search trong database
Ô tìm kiếm sản phẩm, tìm bài viết theo từ khoá, tìm khách hàng theo tên: đều là tìm từ bên trong một đoạn văn bản. Trang này giải thích vì sao LIKE '%từ khoá%' không dùng được index thường, inverted index hoạt động thế nào, MySQL và PostgreSQL làm full-text search ra sao, những điểm cần chú ý với tiếng Việt, và khi nào nên chuyển sang search engine riêng.
Vì sao LIKE '%…%' chậm
Index B+Tree sắp xếp giá trị theo thứ tự từ đầu chuỗi, giống danh bạ sắp theo họ. LIKE 'Điện thoại%' tìm được trong index vì biết phần đầu. Nhưng LIKE '%tai nghe%' không biết phần đầu, nên database phải đọc từng dòng và kiểm tra (MySQL chương 4). Với vài nghìn dòng thì không sao. Với vài trăm nghìn dòng trở lên thì mỗi lần tìm là một lần quét cả bảng, khoảng trên trăm ms với 500.000 tên sản phẩm trên lab, và tăng tuyến tính theo số dòng.
Inverted index
Mọi hệ thống tìm kiếm, từ FULLTEXT của MySQL tới Elasticsearch, đều dựa trên cùng một ý: inverted index. Thay vì lưu "tài liệu nào chứa những từ nào", nó lưu ngược lại: "mỗi từ nằm trong những tài liệu nào", giống phần chỉ mục ở cuối sách.
- Tách từ (tokenize): cắt văn bản thành từ, thường theo khoảng trắng và dấu câu, chuyển về chữ thường.
- Chuẩn hoá: tuỳ cấu hình, bỏ dấu, bỏ stopword (từ quá phổ biến như "the", "a"), đưa từ về dạng gốc (stemming, ví dụ "running" thành "run", chỉ có ở một số ngôn ngữ).
- Ghi vào index: mỗi từ trỏ tới danh sách tài liệu chứa nó, gọi là posting list.
Tìm "điện thoại samsung" là lấy posting list của từng từ rồi lấy giao của chúng. Không phải đọc tài liệu nào không chứa đủ các từ. Thử sửa danh sách tài liệu, bật bỏ dấu hay đổi độ dài từ tối thiểu:
#2, #6tài liệu khớp
27từ trong index
| Từ | Có trong tài liệu |
|---|---|
| 12 | #5 |
| anker | #5 |
| áo | #1, #4 |
| bao | #6 |
| bảo | #5 |
| chính | #2, #3 |
| coolmate | #1 |
| da | #6 |
| dự | #5 |
| đen | #1 |
| điện | #2, #6 |
| giá | #4 |
| hãng | #2, #3 |
| hành | #5 |
| khoác | #4 |
| màu | #1 |
| nghe | #3 |
| phòng | #5 |
| rẻ | #4 |
| sạc | #5 |
| samsung | #2, #6 |
| sony | #3 |
| tai | #3 |
| tháng | #5 |
| thoại | #2, #6 |
| thun | #1 |
| uniqlo | #4 |
Mỗi từ trỏ tới danh sách tài liệu chứa nó. Query nhiều từ là lấy giao của các danh sách, nên không phải đọc từng tài liệu như LIKE '%…%'.
Tách từ theo khoảng trắng như parser mặc định của MySQL và cấu hình 'simple' của PostgreSQL; không xếp hạng, không stemming.
MySQL: FULLTEXT index
sql
ALTER TABLE products ADD FULLTEXT INDEX ft_title (title);
-- Natural language mode: xếp hạng theo độ liên quan
SELECT id, title FROM products WHERE MATCH(title) AGAINST ('tai nghe sony');
-- Boolean mode: + là bắt buộc có, - là không được có, "…" là cả cụm
SELECT id, title FROM products
WHERE MATCH(title) AGAINST ('+tai +nghe +sony -"giá rẻ"' IN BOOLEAN MODE);Ba điều cần biết:
- Từ ngắn hơn
innodb_ft_min_token_size(mặc định 3) không được index. Với tiếng Việt đây là vấn đề lớn, vì rất nhiều từ chỉ có hai chữ: "áo", "đi", "ăn", "xe". Trên lab, tìm "áo" bằng FULLTEXT ra 0 dòng, dù có hơn 60.000 sản phẩm tên bắt đầu bằng "Áo". Muốn sửa phải hạ biến này xuống 1 hoặc 2. Đây là biến cấu hình lúc khởi động, nên đổi xong phải restart server rồi tạo lại FULLTEXT index. - Stopword. InnoDB có sẵn một danh sách 36 stopword tiếng Anh, không ảnh hưởng tới từ tiếng Việt. Có thể thay bằng danh sách riêng.
- Parser. Parser mặc định tách theo khoảng trắng, hợp với tiếng Việt vì các âm tiết đã cách nhau. Parser ngram (mặc định 2 ký tự) dành cho tiếng Trung, Nhật, Hàn, nơi các từ viết liền nhau.
Độ nhanh của FULLTEXT trên InnoDB ở mức vừa phải. Trên lab, query ba từ trên 500.000 dòng mất khoảng 35–50 ms, nhanh hơn LIKE khoảng ba lần nhưng chậm hơn GIN của PostgreSQL nhiều.
PostgreSQL: tsvector, GIN và pg_trgm
PostgreSQL tách văn bản thành tsvector (danh sách từ đã chuẩn hoá) và câu tìm thành tsquery, rồi đặt index GIN (một dạng inverted index) lên tsvector:
sql
ALTER TABLE products ADD COLUMN tsv tsvector
GENERATED ALWAYS AS (to_tsvector('simple', title)) STORED;
CREATE INDEX products_tsv ON products USING gin (tsv);
SELECT id, title, ts_rank(tsv, q) AS rank
FROM products, to_tsquery('simple', 'tai & nghe & sony') AS q
WHERE tsv @@ q
ORDER BY rank DESC LIMIT 20;
-- Tìm cả cụm theo đúng thứ tự
SELECT id FROM products WHERE tsv @@ phraseto_tsquery('simple', 'tai nghe sony');PostgreSQL không có cấu hình riêng cho tiếng Việt. Cấu hình 'simple' chỉ tách theo khoảng trắng và chuyển chữ thường, không bỏ từ nào, nên "áo" vẫn tìm được. GIN rất nhanh: query ba từ trên 500.000 dòng chỉ mất vài ms trên lab.
Extension pg_trgm cho cách khác: cắt chuỗi thành các cụm 3 ký tự (trigram) và đặt GIN lên đó. Theo tài liệu, index này hỗ trợ thẳng LIKE, ILIKE và biểu thức chính quy, nên ILIKE '%tai nghe sony%' dùng được index mà không phải đổi query. pg_trgm còn có toán tử so độ giống nhau, hợp với tìm tên khi người dùng gõ sai chính tả.
Tìm theo từ (tsvector + GIN) | Tìm theo chuỗi con (pg_trgm + GIN) | |
|---|---|---|
| Query | tsv @@ to_tsquery(…) | title ILIKE '%…%', similarity() |
| Khớp | Từ nguyên vẹn | Bất kỳ đoạn nào trong chuỗi, kể cả giữa từ |
| Xếp hạng | ts_rank | Độ giống nhau |
| Hợp với | Tìm bài viết, mô tả sản phẩm | Tìm tên, mã, gõ sai chính tả |
Tiếng Việt
Tách từ. Tiếng Việt viết cách nhau theo âm tiết, nên "điện thoại" là hai token "điện" và "thoại". Tìm từng từ riêng có thể khớp nhầm ("thoại" trong "hội thoại"). Khi cần đúng cụm thì dùng tìm theo cụm: dấu ngoặc kép trong boolean mode của MySQL, phraseto_tsquery của PostgreSQL. Tách từ thật sự theo nghĩa cần thư viện xử lý tiếng Việt, thường làm ở search engine riêng.
Dấu. Người dùng hay gõ không dấu ("dien thoai"), và kết quả vẫn phải ra "điện thoại".
- MySQL: collation mặc định
utf8mb4_0900_ai_cikhông phân biệt dấu (ailà accent insensitive), và coi cảđbằngd. FULLTEXT dùng collation của cột, nên tìm "dien thoai" ra đúng "điện thoại" mà không phải làm gì thêm. - PostgreSQL phân biệt dấu. Dùng extension
unaccentđể bỏ dấu cả lúc index lẫn lúc tìm.unaccent()không được đánh dấu IMMUTABLE nên không đặt thẳng vào index được, phải bọc trong một hàm IMMUTABLE:
sql
CREATE EXTENSION unaccent;
CREATE FUNCTION f_unaccent(text) RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE
AS $$ SELECT public.unaccent('public.unaccent', $1) $$;
CREATE INDEX products_tsv_unaccent ON products
USING gin (to_tsvector('simple', f_unaccent(title)));
SELECT id FROM products
WHERE to_tsvector('simple', f_unaccent(title)) @@ to_tsquery('simple', f_unaccent('dien & thoai'));Bỏ dấu có cái giá là gộp các từ khác nghĩa: "bao" khớp cả "bảo", "ban" khớp cả "bàn", "bán", "bạn". Trên lab, tìm "bao" ra gần 95.000 sản phẩm, phần lớn là vì có cụm "bảo hành". Cách hay dùng là tìm trên cả hai: bản bỏ dấu để không sót, bản có dấu để xếp những kết quả khớp đúng dấu lên đầu.
Khi nào chuyển sang search engine riêng
Full-text search của database đủ cho nhiều ứng dụng: tìm trong vài triệu dòng, khớp theo từ, xếp hạng đơn giản. Nên cân nhắc Elasticsearch, OpenSearch hay Meilisearch khi cần:
- Xếp hạng phức tạp: kết hợp độ liên quan với độ phổ biến, giá, khoảng cách; tăng trọng số cho tên so với mô tả.
- Gợi ý khi gõ (autocomplete), sửa lỗi chính tả, từ đồng nghĩa.
- Lọc nhiều chiều kèm số đếm (facet): "Samsung (120), Apple (85)" theo hãng, khoảng giá, đánh giá.
- Tách từ tiếng Việt theo nghĩa, qua plugin phân tích ngôn ngữ.
- Dữ liệu và lượng tìm kiếm lớn tới mức làm nặng database chính.
Cái giá là thêm một hệ thống phải vận hành, và dữ liệu trong search engine luôn chậm hơn database một chút. Database vẫn là nguồn dữ liệu chính, còn search engine là bản sao để tìm. Đồng bộ bằng CDC đọc binlog hay WAL, hoặc bằng outbox (Hàng đợi bằng database), thay vì để app ghi vào cả hai nơi, vì ghi hai nơi thì một bên lỗi sẽ làm hai bên lệch nhau.
Ghi nhớ
LIKE '%…%'không dùng được index B+Tree nên phải quét cả bảng. Full-text search dùng inverted index: mỗi từ trỏ tới danh sách tài liệu chứa nó.- MySQL FULLTEXT mặc định bỏ từ ngắn hơn 3 ký tự, nên "áo", "đi" không tìm được; phải hạ
innodb_ft_min_token_size, restart và tạo lại index. - PostgreSQL dùng
tsvectorvới GIN cho tìm theo từ,pg_trgmchoLIKE/ILIKEvà gõ sai chính tả. Tìm không dấu cầnunaccentbọc trong hàm IMMUTABLE. - Collation
ai_cicủa MySQL tìm không dấu sẵn, nhưng gộp cả các từ khác nghĩa ("bao" và "bảo"). - Chuyển sang search engine khi cần xếp hạng phức tạp, autocomplete, facet hay tách từ tiếng Việt; đồng bộ bằng CDC hoặc outbox.
Nguồn tham khảo
Đã đối chiếu (đọc nguồn ngày 2026-10-23):
| Nguồn | Dùng cho phần |
|---|---|
| PostgreSQL 17 docs, pg_trgm | Index GIN và GiST của pg_trgm hỗ trợ LIKE, ILIKE, ~, ~*; so độ giống nhau |
| MySQL 8.0 Reference Manual, Full-Text Stopwords, ngram Full-Text Parser | Danh sách stopword mặc định trong INNODB_FT_DEFAULT_STOPWORD; parser ngram cho tiếng Trung, Nhật, Hàn |
Kiểm bằng lab, chưa đọc mục tương ứng trong tài liệu: innodb_ft_min_token_size = 3, ngram_token_size = 2, 36 stopword mặc định (đọc bằng SELECT @@…); utf8mb4_0900_ai_ci coi d bằng đ; lỗi "functions in index expression must be marked IMMUTABLE" khi dùng thẳng unaccent() trong index. Từ kiến thức chung: innodb_ft_min_token_size là biến lúc khởi động, đổi xong phải tạo lại index; tính năng của Elasticsearch, OpenSearch, Meilisearch.
Số đo chi tiết
Đo bằng script lab/full-text-search/search.mjs ngày 2026-10-23 trên MacBook M2 (MySQL 8.0, PostgreSQL 17 trong Docker). 500.000 tên sản phẩm ghép từ 15 loại hàng, 15 hãng và 10 cụm mô tả, ví dụ "Tai nghe Sony chính hãng màu đen mã 1234". Mỗi query lấy thời gian tốt nhất trong 3 lần.
Tìm "tai nghe sony" (2.223 dòng khớp)
Ba lượt:
| Cách | MySQL 8.0 | PostgreSQL 17 |
|---|---|---|
LIKE / ILIKE '%tai nghe sony%', không index | 137–151 ms | 113–123 ms |
FULLTEXT, boolean mode +tai +nghe +sony | 35–49 ms (tạo index 12–15 giây) | |
FULLTEXT, cả cụm "tai nghe sony" | 29–39 ms | |
tsvector + GIN, tai & nghe & sony | 3,1–4,1 ms (tạo index 2,2–2,5 giây, 34 MB) | |
phraseto_tsquery | 3,5–4,0 ms | |
ILIKE '%tai nghe sony%' với GIN pg_trgm | 7,4–8,0 ms (tạo index 3,6–4,0 giây, 36 MB) |
Tiếng Việt
- MySQL, tìm "áo" bằng FULLTEXT: 0 dòng, trong khi 66.667 sản phẩm bắt đầu bằng "Áo" (
innodb_ft_min_token_size = 3). PostgreSQL với'simple': 66.667 dòng. - MySQL, collation
utf8mb4_0900_ai_ci: "khoac" ra 33.333 dòng ("Áo khoác"); "dien thoai" và "điện thoại" đều ra 33.334 dòng;'d' = 'đ'và'a' = 'á'đều đúng. Tìm "bao" ra 94.500 dòng vì khớp "bảo hành". - PostgreSQL,
tsvectorthường: "dien & thoai" ra 0 dòng. Index trênto_tsvector('simple', f_unaccent(title)): ra 33.334 dòng trong khoảng 40 ms.
Code đã dùng để chạy thử
Chạy bằng npm run lab -- full-text-search sau npm run lab:up, khoảng 30 giây. Xem Lab.
lab/full-text-search/search.mjs
js
// Full-text search trong MySQL 8.0 và PostgreSQL 17, với tên sản phẩm tiếng Việt.
// 1. 500.000 tên sản phẩm: LIKE '%…%' (quét cả bảng) so với FULLTEXT (MySQL), GIN trên tsvector và pg_trgm (PostgreSQL).
// 2. Tiếng Việt: từ hai ký tự ("áo") có được index không, tìm không dấu ("dien thoai") có ra "điện thoại" không.
import mysql from 'mysql2/promise'
import pg from 'pg'
import { MYSQL, PG } from '../common/config.mjs'
import { ensureDb } from '../common/run-sql.mjs'
const N = 500_000
const ms = (v) => (v >= 100 ? Math.round(v).toLocaleString('vi-VN') : v.toFixed(1).replace('.', ',')) + ' ms'
const mb = (b) => (Number(b) / 1e6).toFixed(0) + ' MB'
async function best(fn, n = 3) { let m = Infinity, r; for (let i = 0; i < n; i++) { const t0 = performance.now(); r = await fn(); m = Math.min(m, performance.now() - t0) } return [m, r] }
// Tên sản phẩm ghép từ các cụm từ, giống dữ liệu một sàn thương mại điện tử.
const A = ['Điện thoại', 'Áo thun', 'Quần jean', 'Tai nghe', 'Laptop', 'Bàn phím', 'Chuột không dây', 'Nồi cơm điện', 'Máy lọc nước', 'Giày thể thao', 'Áo khoác', 'Đồng hồ', 'Sạc dự phòng', 'Bình giữ nhiệt', 'Ốp lưng']
const B = ['Samsung', 'Apple', 'Xiaomi', 'Sony', 'Logitech', 'Sunhouse', 'Kangaroo', 'Nike', 'Adidas', 'Casio', 'Anker', 'Lock&Lock', 'Coolmate', 'Uniqlo', 'Asus']
const C = ['chính hãng', 'giá rẻ', 'cao cấp', 'màu đen', 'màu trắng', 'size L', 'bảo hành 12 tháng', 'mới 100%', 'nhập khẩu', 'phiên bản 2025']
const title = (i) => `${A[i % A.length]} ${B[Math.floor(i / A.length) % B.length]} ${C[Math.floor(i / 225) % C.length]} ${C[(Math.floor(i / 2250) + 3) % C.length]} mã ${i}`
const rows = Array.from({ length: N }, (_, i) => [i + 1, title(i)])
ensureDb('mysql', 'fts')
ensureDb('pg', 'fts')
// ---------- MySQL ----------
const my = await mysql.createConnection({ ...MYSQL, database: 'fts', charset: 'utf8mb4' })
const one = async (sql, a) => Object.values((await my.query(sql, a))[0][0])[0]
await my.query('DROP TABLE IF EXISTS products')
await my.query('CREATE TABLE products (id INT PRIMARY KEY, title VARCHAR(200) NOT NULL) DEFAULT CHARSET = utf8mb4 COLLATE utf8mb4_0900_ai_ci')
for (let i = 0; i < N; i += 5000) await my.query('INSERT INTO products VALUES ?', [rows.slice(i, i + 5000)])
console.log(`\n=== MySQL 8.0: ${N.toLocaleString('vi-VN')} tên sản phẩm ===`)
let [t, n] = await best(() => one(`SELECT COUNT(*) FROM products WHERE title LIKE '%tai nghe sony%'`))
console.log(`LIKE '%tai nghe sony%': ${n} dòng, ${ms(t)} (quét cả bảng)`)
let t0 = performance.now()
await my.query('ALTER TABLE products ADD FULLTEXT INDEX ft_title (title)')
console.log(`Tạo FULLTEXT index: ${ms(performance.now() - t0)}`)
;[t, n] = await best(() => one(`SELECT COUNT(*) FROM products WHERE MATCH(title) AGAINST ('+tai +nghe +sony' IN BOOLEAN MODE)`))
console.log(`MATCH … AGAINST ('+tai +nghe +sony' IN BOOLEAN MODE): ${n} dòng, ${ms(t)}`)
;[t, n] = await best(() => one(`SELECT COUNT(*) FROM products WHERE MATCH(title) AGAINST ('"tai nghe sony"' IN BOOLEAN MODE)`))
console.log(`Tìm cả cụm "tai nghe sony": ${n} dòng, ${ms(t)}`)
console.log(`innodb_ft_min_token_size = ${await one('SELECT @@innodb_ft_min_token_size')}`)
console.log(`Tìm "áo" (2 ký tự): ${await one(`SELECT COUNT(*) FROM products WHERE MATCH(title) AGAINST ('+áo' IN BOOLEAN MODE)`)} dòng; thật ra có ${await one(`SELECT COUNT(*) FROM products WHERE title LIKE 'Áo %'`)} sản phẩm bắt đầu bằng "Áo"`)
console.log(`Không dấu cũng có cái giá: tìm "bao" ra ${await one(`SELECT COUNT(*) FROM products WHERE MATCH(title) AGAINST ('+bao' IN BOOLEAN MODE)`)} dòng, vì khớp cả "bảo hành"`)
console.log(`Tìm không dấu "khoac" (collation ai_ci): ${await one(`SELECT COUNT(*) FROM products WHERE MATCH(title) AGAINST ('+khoac' IN BOOLEAN MODE)`)} dòng; "dien thoai": ${await one(`SELECT COUNT(*) FROM products WHERE MATCH(title) AGAINST ('+dien +thoai' IN BOOLEAN MODE)`)} dòng; "điện thoại": ${await one(`SELECT COUNT(*) FROM products WHERE MATCH(title) AGAINST ('+điện +thoại' IN BOOLEAN MODE)`)} dòng`)
console.log(`So sánh bằng collation: 'd' = 'đ' → ${await one(`SELECT 'd' = 'đ' COLLATE utf8mb4_0900_ai_ci`)}, 'a' = 'á' → ${await one(`SELECT 'a' = 'á' COLLATE utf8mb4_0900_ai_ci`)}`)
await my.end()
// ---------- PostgreSQL ----------
const p = new pg.Client({ ...PG, database: 'fts' })
await p.connect()
const pone = async (sql, a) => Object.values((await p.query(sql, a)).rows[0])[0]
await p.query('CREATE EXTENSION IF NOT EXISTS pg_trgm')
await p.query('CREATE EXTENSION IF NOT EXISTS unaccent')
await p.query('DROP TABLE IF EXISTS products')
await p.query('CREATE TABLE products (id INT PRIMARY KEY, title TEXT NOT NULL)')
for (let i = 0; i < N; i += 5000) {
const chunk = rows.slice(i, i + 5000)
await p.query(`INSERT INTO products SELECT * FROM UNNEST($1::int[], $2::text[])`, [chunk.map((r) => r[0]), chunk.map((r) => r[1])])
}
await p.query('VACUUM ANALYZE products')
console.log(`\n=== PostgreSQL 17: ${N.toLocaleString('vi-VN')} tên sản phẩm ===`)
;[t, n] = await best(() => pone(`SELECT COUNT(*) FROM products WHERE title ILIKE '%tai nghe sony%'`))
console.log(`ILIKE '%tai nghe sony%': ${n} dòng, ${ms(t)} (quét cả bảng)`)
// Cấu hình 'simple': tách theo khoảng trắng, chuyển chữ thường, không stemming (PostgreSQL không có cấu hình tiếng Việt).
await p.query(`ALTER TABLE products ADD COLUMN tsv tsvector GENERATED ALWAYS AS (to_tsvector('simple', title)) STORED`)
t0 = performance.now()
await p.query('CREATE INDEX products_tsv ON products USING gin (tsv)')
const tGin = performance.now() - t0
;[t, n] = await best(() => pone(`SELECT COUNT(*) FROM products WHERE tsv @@ to_tsquery('simple', 'tai & nghe & sony')`))
console.log(`Tạo GIN trên tsvector: ${ms(tGin)}, ${mb(await pone(`SELECT pg_relation_size('products_tsv')`))}; tsv @@ 'tai & nghe & sony': ${n} dòng, ${ms(t)}`)
;[t, n] = await best(() => pone(`SELECT COUNT(*) FROM products WHERE tsv @@ phraseto_tsquery('simple', 'tai nghe sony')`))
console.log(`Tìm cả cụm (phraseto_tsquery): ${n} dòng, ${ms(t)}`)
t0 = performance.now()
await p.query('CREATE INDEX products_trgm ON products USING gin (title gin_trgm_ops)')
const tTrgm = performance.now() - t0
;[t, n] = await best(() => pone(`SELECT COUNT(*) FROM products WHERE title ILIKE '%tai nghe sony%'`))
console.log(`Tạo GIN pg_trgm: ${ms(tTrgm)}, ${mb(await pone(`SELECT pg_relation_size('products_trgm')`))}; ILIKE '%tai nghe sony%' dùng pg_trgm: ${n} dòng, ${ms(t)}`)
console.log(`Tìm "áo": ${await pone(`SELECT COUNT(*) FROM products WHERE tsv @@ to_tsquery('simple', 'áo')`)} dòng`)
console.log(`Tìm không dấu "dien & thoai" trên tsvector thường: ${await pone(`SELECT COUNT(*) FROM products WHERE tsv @@ to_tsquery('simple', 'dien & thoai')`)} dòng`)
console.log(`unaccent('Điện thoại Ốp lưng') = '${await pone(`SELECT unaccent('Điện thoại Ốp lưng')`)}'`)
// unaccent() không được đánh dấu IMMUTABLE nên không dùng thẳng trong index; bọc trong một hàm IMMUTABLE.
await p.query(`CREATE OR REPLACE FUNCTION f_unaccent(text) RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ SELECT public.unaccent('public.unaccent', $1) $$`)
await p.query(`CREATE INDEX products_tsv_unaccent ON products USING gin (to_tsvector('simple', f_unaccent(title)))`)
;[t, n] = await best(() => pone(`SELECT COUNT(*) FROM products WHERE to_tsvector('simple', f_unaccent(title)) @@ to_tsquery('simple', f_unaccent('dien & thoai'))`))
console.log(`Index trên to_tsvector('simple', f_unaccent(title)): tìm "dien & thoai" ra ${n} dòng, ${ms(t)}`)
const { rows: rk } = await p.query(`SELECT title, ts_rank(tsv, q) AS r FROM products, to_tsquery('simple', 'sony | samsung') q WHERE tsv @@ q ORDER BY r DESC, id LIMIT 2`)
console.log(`ts_rank cho 'sony | samsung', 2 kết quả đầu: ${rk.map((r) => `${r.title} (${Number(r.r).toFixed(3)})`).join('; ')}`)
await p.end()