NHT

Hybrid search: full-text + vector trong một query Postgres

Kết hợp full-text search và pgvector trong một query Postgres bằng Reciprocal Rank Fusion — vừa chính xác từ khoá vừa bao phủ ngữ nghĩa, kèm benchmark thật.

Nguyen Hoang TuanNguyen Hoang Tuan25 thg 6, 20269 phút đọc

Bài lưu embeddings trong Postgres kết thúc ở một nút thắt: vector search rất giỏi "tìm theo ngữ nghĩa" nhưng lại xếp hạng kém với các trường hợp khớp từ khoá chính xác. Cách sửa không phải chọn một bên thắng — mà chạy cả hai rồi trộn kết quả. Bài này dựng hybrid search: full-text search của PostgreSQL cho độ chính xác từ khoá, pgvector cho độ bao phủ ngữ nghĩa, hợp nhất trong một query bằng một công thức fusion. Không cần database thứ hai, không cần vòng lặp trộn ở tầng app.

Ta tiếp tục ví dụ xuyên suốt series — bảng documents chứa các đoạn tài liệu API GoGoDuk và kho kiến thức địa chỉ tiếng Việt, mỗi dòng mang content (text) và embedding (vector 1536 chiều). Ta sẽ thêm nửa full-text vào cạnh nửa vector đã có sẵn.

Vấn đề: keyword và semantic mỗi cái đều bỏ sót

Hai phương pháp retrieval này thất bại ở những input trái ngược nhau — và đó chính là lý do ghép chúng lại thì ăn:

  • Full-text search khớp theo token. Query "rate limit 429" tìm ra đúng doc chứa chữ 429 — một model vector thường làm nhoè mã chính xác đó thành "lỗi nói chung". Nhưng FTS chịu thua với "làm sao chặn quá nhiều request": không có từ khoá chung nào, ra zero kết quả.
  • Vector search khớp theo ngữ nghĩa. "làm sao chặn quá nhiều request" lôi ra được doc rate-limiting dù không có chữ nào trùng. Nhưng nếu hỏi mã lỗi "E_QUOTA_EXCEEDED" thì nearest-neighbour mờ mờ có thể xếp ba đoạn liên quan lờ mờ lên trên đoạn khớp chính xác.

Một luồng query thật chứa cả hai dạng: định danh chính xác (SKU, mã lỗi, tên đường) và ngôn ngữ tự nhiên mơ hồ. Chỉ-keyword thì mất các câu diễn giải; chỉ-semantic thì mất các token chính xác. Hybrid search giữ lại ứng viên mà một trong hai phương pháp xếp hạng cao, rồi để fusion quyết định thứ tự cuối.

Hai nửa: tsvector và pgvector trên cùng một dòng

Cột vector đã có sẵn từ bài trước. Ta thêm một tsvector cho nửa keyword. Để không phân biệt dấu cho tiếng Việt, hãy sinh nó qua unaccent — đúng kỹ thuật mà bài FTS tiếng Việt mổ xẻ kỹ:

-- Một config riêng để dictionary strip dấu (Nguyễn -> nguyen).
CREATE TEXT SEARCH CONFIGURATION vi_unaccent ( COPY = simple );
ALTER TEXT SEARCH CONFIGURATION vi_unaccent
  ALTER MAPPING FOR asciiword, word, hword, hword_part
  WITH unaccent, simple;

-- Cột tsvector generated, tự đồng bộ.
ALTER TABLE documents
  ADD COLUMN content_tsv tsvector
  GENERATED ALWAYS AS (to_tsvector('vi_unaccent', content)) STORED;

CREATE INDEX documents_tsv_idx ON documents USING gin (content_tsv);

Giờ mỗi dòng mang cả hai tín hiệu nằm cạnh nhau: content_tsv cho đường GIN/keyword, embedding cho đường HNSW/semantic. Cùng bảng, cùng backup, cùng transaction — chính cái tính chất này khiến phần còn lại là một query duy nhất thay vì hai hệ thống thương lượng qua mạng.

Vì sao không thể cộng thẳng điểm lại

Phản xạ ngây thơ là ORDER BY ts_rank(...) + (1 - (embedding <=> $q)). Nó không chạy, vì hai điểm số sống trên hai thang đo không tương thích:

  • ts_rank là một số relevance không chặn (phụ thuộc tần suất từ, độ dài tài liệu, trọng số) — trên corpus này có thể chạy 0.0–0.6, trên corpus khác lại 0.0–12.0.
  • Cosine distance (<=>) bị chặn 0–2, và các match "tốt" túm tụm trong dải hẹp gần 0.

Cộng lại thì bên nào tình cờ có dải số lớn hơn sẽ âm thầm áp đảo; trọng số bạn tưởng mình đặt chỉ là ảo. Bạn có thể min-max normalize mỗi điểm theo từng query, nhưng cách đó mong manh — chỉ một outlier là rescale toàn bộ. Nước đi chắc chắn là vứt bỏ điểm thô và fuse theo vị trí rank thay vì giá trị.

Reciprocal Rank Fusion: công thức trộn điểm

Reciprocal Rank Fusion (RRF) bỏ qua độ lớn của từng điểm và chỉ dùng vị trí mà tài liệu đứng trong mỗi danh sách. Một tài liệu ở rank r đóng góp 1 / (k + r) vào điểm fusion, cộng dồn qua cả hai danh sách:

score(doc) = Σ  1 / (k + rank_trong_list)
            lists

k là hằng số nhỏ (mặc định chuẩn là 60) làm dịu khoảng cách giữa rank 1 và rank 2, để một danh sách không thể áp đảo hoàn toàn. Cái hay của RRF là những thứ nó không cần: không normalize theo query, không cần tinh chỉnh trọng số giữa "tầm quan trọng keyword" và "tầm quan trọng semantic", không giả định hai điểm số có thể so sánh. Một doc đứng #1 ở keyword và #50 ở vector vẫn được điểm tốt; một doc đứng #2 ở cả hai thường thắng luôn — đúng hành vi "cả hai cùng thích, và một bên thì rất thích" mà ta muốn.

Đó cũng là lý do RRF là cú nâng chất lượng rẻ nhất trong retrieval, cùng tinh thần với reranking — chỉ khác là RRF gần như miễn phí, vì cả hai bảng xếp hạng đều ra từ cùng một database.

Tất cả trong một câu SQL

Postgres cho ta ROW_NUMBER() để biến mỗi ORDER BY thành một rank, và FULL OUTER JOIN để hợp hai tập ứng viên (một doc chỉ được một phương pháp tìm ra vẫn tham gia). Bạn truyền hai tham số: $1 là chuỗi tsquery, $2 là embedding của câu hỏi.

WITH fts AS (
  SELECT id,
         ROW_NUMBER() OVER (ORDER BY ts_rank(content_tsv, query) DESC) AS rank
  FROM documents, websearch_to_tsquery('vi_unaccent', $1) query
  WHERE content_tsv @@ query
  LIMIT 50
),
vec AS (
  SELECT id,
         ROW_NUMBER() OVER (ORDER BY embedding <=> $2) AS rank
  FROM documents
  WHERE embedding IS NOT NULL
  ORDER BY embedding <=> $2
  LIMIT 50
)
SELECT d.id, d.content,
       COALESCE(1.0 / (60 + fts.rank), 0.0) +
       COALESCE(1.0 / (60 + vec.rank), 0.0) AS rrf_score
FROM fts
FULL OUTER JOIN vec USING (id)
JOIN documents d USING (id)
ORDER BY rrf_score DESC
LIMIT 10;

Đọc từ trên xuống: fts xếp hạng các match keyword, vec xếp hạng các nearest neighbour, FULL OUTER JOIN trộn chúng theo id, và COALESCE(..., 0.0) nghĩa là "không có trong list này" đóng góp 0 thay vì NULL. Mỗi bên cắt ở 50 ứng viên để join rẻ; LIMIT 10 cuối là cái bạn đưa cho LLM. Một round trip, một transaction, cả hai index (GIN + HNSW) cùng làm việc — và tập kết quả luôn là tập cha của những gì từng phương pháp đơn lẻ trả về.

Benchmark: hybrid vs FTS vs vector

Số liệu từ corpus tài liệu/địa chỉ GoGoDuk (~1 triệu chunk, embedding 1536 chiều, index HNSW + GIN nóng trong RAM), đánh giá trên bộ 200 query gán nhãn tay, chia đôi giữa câu khớp-token-chính-xác và câu ngôn-ngữ-tự-nhiên. Metric là recall@10 — đoạn đúng có lọt top 10 không:

| Phương pháp | Recall@10 (query token chính xác) | Recall@10 (query ngôn ngữ tự nhiên) | p95 latency | | --- | --- | --- | --- | | Chỉ FTS | 0.94 | 0.61 | ~12 ms | | Chỉ Vector | 0.70 | 0.91 | ~18 ms | | Hybrid (RRF) | 0.95 | 0.93 | ~24 ms |

Câu chuyện nằm ở các ô chéo: FTS sụp xuống 0.61 với câu diễn giải, vector rớt còn 0.70 với token chính xác, còn hybrid giữ cao ở cả hai — nó thừa hưởng điểm mạnh của mỗi bên và vá điểm mù của bên kia. Cái giá là thêm một index scan và một join: p95 tăng khoảng gấp đôi so với một phương pháp, nhưng vẫn thấp hơn nhiều so với bất kỳ lệnh gọi LLM nào tiêu thụ kết quả đó, nên trên ngân sách request end-to-end nó chỉ là nhiễu. Hãy coi con số tuyệt đối là định hướng — chúng dịch theo corpus, model embedding, và k — nhưng hình dạng (hybrid ≥ max của hai cột) thì bền và lặp lại được trên nhiều dataset.

Tinh chỉnh và các cái bẫy

  • k dễ tính, nhưng không miễn phí. 60 là mặc định ổn. k nhỏ làm sắc ảnh hưởng của các hit rank-1 (tốt khi kết quả top của bạn thường đúng); k lớn làm phẳng danh sách (an toàn hơn khi cả hai tín hiệu đều nhiễu). Quét nó trên bộ gán nhãn — chỉ là một con số.
  • Cắt mỗi bên trước khi fuse. LIMIT 50 mỗi CTE rất quan trọng: thiếu nó, một tsquery rộng có thể match hàng chục nghìn dòng và cửa sổ rank phải quét hết. 50 là dư — RRF chỉ quan tâm phần đầu mỗi danh sách.
  • Dùng websearch_to_tsquery, không phải to_tsquery thô. Nó parse input người dùng một cách dễ tính (dấu ngoặc kép, or, từ trần) thay vì ném syntax error vì một ký tự lạ — khác biệt giữa ô tìm kiếm chạy được và ô 500 vì một dấu nháy.
  • Chỉ gán trọng số cho từng nửa khi thật sự cần. RRF không cần trọng số, nhưng nếu domain của bạn nặng keyword thì có thể thiên vị: 1.5 * COALESCE(1.0/(60+fts.rank),0). Thêm trọng số sau cùng, sau khi đã đo — phần lớn corpus không cần.

Đó là hybrid search: hai index có thể bạn đã chạy sẵn, fuse theo rank trong một SELECT. Đây là cú nâng retrieval khiến một pipeline RAG thôi tự làm xấu mặt với các định danh chính xác, và hoàn toàn là Postgres thuần — không service mới phải deploy hay bảo mật. Chỗ đi tiếp là cắt tài liệu đủ tốt để retrieval có chunk ngon mà tìm, và vắt nốt phần chất lượng xếp hạng bằng reranker. Bản đồ cho cả series là bài trụ.

Hệ thống của bạn đang gặp vấn đề hiệu năng hay mở rộng tải?

Tôi chuyên xây dựng hạ tầng bản đồ độ trễ thấp, streaming pipeline thời gian thực (Kafka, ClickHouse) và các hệ thống backend tối ưu. Hãy cùng hợp tác để nâng cấp sản phẩm của bạn.

Hợp tác ngay

Tác giả

Nguyen Hoang Tuan

Nguyen Hoang Tuan

Full-stack developer focused on practical backend architecture, web performance, and production delivery.

Bài viết liên quan