Published on

Day 36: Hybrid Search Production

Authors

1. Bài toán cần giải quyết

Trong RAG, retriever quyết định LLM nhìn thấy tài liệu nào. Nếu retriever bỏ sót tài liệu đúng, prompt tốt đến đâu cũng khó cứu được câu trả lời.

Một hệ thống RAG đơn giản thường bắt đầu bằng vector search:

user query
  -> embedding query
  -> vector search top_k
  -> build context
  -> LLM answer

Cách này tốt cho câu hỏi semantic, nhưng dễ thất bại với query chứa mã lỗi, tên riêng, acronym, SKU, điều khoản pháp lý hoặc từ khóa rất ngắn:

"HTTP 429"
"SLA P1 enterprise"
"VAT invoice"
"CVE-2024-..."
"điều 12.3"
"BAAI/bge-m3 normalize_embeddings"

Hybrid search giải quyết bằng cách chạy ít nhất hai retrieval path:

query
  -> BM25 / sparse search
  -> dense vector search
  -> merge candidates
  -> optional reranker
  -> context builder

Điểm quan trọng: hybrid search không phải thêm search engine cho vui. Nó là cách giảm rủi ro retrieval miss khi corpus có cả ngôn ngữ tự nhiên lẫn từ khóa exact.

2. Mục tiêu học

Sau bài này bạn cần làm được các việc sau:

  • Phân biệt dense retrieval, sparse retrieval, BM25 và SPLADE.
  • Hiểu vì sao BM25 vẫn cần thiết dù đã có embedding model tốt.
  • Thiết kế pipeline BM25 top-k + vector top-k + merge bằng Reciprocal Rank Fusion.
  • Biết query nào keyword-heavy, query nào semantic-heavy, query nào mixed.
  • Biết normalize query mà không làm mất mã lỗi, acronym, số hiệu và thuật ngữ.
  • Biết đánh giá bằng Hit@K, Recall@K, MRR@K, nDCG@K và latency percentile.
  • Trả lời được câu hỏi production readiness cho hybrid search.

3. Dense retrieval

Dense retrieval biến query và document chunk thành embedding vector. Search được thực hiện bằng similarity trong vector space, thường là cosine similarity hoặc dot product.

query text -> query embedding
chunk text -> chunk embedding
similarity(query embedding, chunk embedding) -> top_k chunks

Dense retrieval mạnh ở semantic similarity:

QueryChunk đúngVì sao dense hữu ích
"tôi muốn lấy lại tiền""Khách hàng có thể yêu cầu hoàn tiền..."Query và document dùng từ khác nhau
"làm sao đổi email đăng nhập""Cập nhật địa chỉ email trong profile..."Ý nghĩa gần nhau dù wording khác
"policy nghỉ khi có việc gia đình""Compassionate leave..."Cross-lingual hoặc mixed language nếu model hỗ trợ

Điểm mạnh:

  • Bắt được synonym, paraphrase và intent.
  • Hợp với câu hỏi dài, mô tả bằng ngôn ngữ tự nhiên.
  • Hữu ích khi user không biết đúng thuật ngữ trong tài liệu.
  • Có thể hỗ trợ multilingual nếu embedding model được train phù hợp.

Điểm yếu:

  • Dễ bỏ qua exact token như HTTP 429, P1, VAT, S3, C++, SKU-123.
  • Vector score khó giải thích cho user và support engineer.
  • Chất lượng phụ thuộc mạnh vào embedding model, chunking strategy và domain benchmark.
  • Query quá ngắn có ít semantic signal.
  • ANN search có thể mất recall nếu index được tune quá aggressive.

Kết luận thực tế: dense retrieval nên được xem là semantic candidate generator, không phải toàn bộ search system.

4. Sparse retrieval

Sparse retrieval biểu diễn query/document bằng token hoặc term. Vector sparse thường có kích thước rất lớn, mỗi chiều tương ứng một token trong vocabulary, nhưng phần lớn giá trị bằng 0.

Các dạng sparse retrieval phổ biến:

DạngMô tảKhi dùng
Boolean searchMatch token/phrase/filter đơn giảnAdmin search, debug, filter chính xác
TF-IDFTerm frequency + inverse document frequencyBaseline học thuật, corpus nhỏ
BM25Ranking keyword mạnh, có saturation và length normalizationBaseline production phổ biến
Neural sparse, ví dụ SPLADEDùng model để tạo token weight và query/document expansionKhi cần semantic expansion nhưng vẫn muốn inverted index

Sparse retrieval mạnh khi query và document chia sẻ token quan trọng:

query: "HTTP 429"
doc: "Vượt giới hạn API trả về HTTP 429."

Sparse retrieval yếu khi query dùng synonym:

query: "lấy lại tiền"
doc: "Khách hàng có thể yêu cầu hoàn tiền."

Với tiếng Việt, chất lượng sparse retrieval phụ thuộc rất nhiều vào analyzer/tokenizer. Nếu hệ thống tokenization kém, query không dấu, viết tắt, tiếng Anh lẫn tiếng Việt hoặc dấu câu đặc biệt có thể làm BM25 miss.

5. BM25 là gì?

BM25, viết tắt của Best Matching 25, là thuật toán ranking keyword phổ biến trong search engine. Trực giác:

score cao nếu query term xuất hiện trong document,
term hiếm có trọng số cao hơn term phổ biến,
document quá dài bị normalize,
term lặp nhiều có lợi nhưng bị saturation.

BM25 thường dùng công thức dạng:

score(D, Q) = sum(IDF(q_i) * ((tf(q_i, D) * (k1 + 1)) / (tf(q_i, D) + k1 * (1 - b + b * |D| / avgdl))))

Bạn không cần thuộc công thức để dùng tốt BM25, nhưng cần hiểu các thành phần:

Thành phầnÝ nghĩaTác động
tfTerm frequency trong documentTerm xuất hiện nhiều hơn thì score tăng
IDFTerm hiếm trong corpus có trọng số cao hơnVAT thường quan trọng hơn
`D/ avgdl`
k1Điều khiển saturation của term frequencyCao hơn nghĩa là lặp term còn có thêm lợi ích
bĐiều khiển mức phạt document dài0 bỏ length normalization, 1 dùng mạnh

BM25 tốt cho:

  • Error code: HTTP 429, ORA-00001, ECONNRESET.
  • Acronym: SLA, VAT, SSO, 2FA, PII.
  • Product code, SKU, invoice number, ticket ID.
  • Legal term, clause number, section heading.
  • API name, table name, column name, config key.
  • Query ngắn và keyword-heavy.

BM25 yếu khi:

  • User không dùng cùng wording với tài liệu.
  • Cần hiểu intent dài, mơ hồ hoặc cross-lingual.
  • Document được paraphrase nhiều.
  • Analyzer không xử lý tiếng Việt, dấu, stemming hoặc phrase đúng.

6. SPLADE overview

SPLADE là neural sparse retrieval. Thay vì chỉ đếm token như BM25, SPLADE dùng transformer để tạo sparse vector có token weight. Model có thể "expand" query/document thành các token liên quan.

Ví dụ trực giác:

query: "lấy lại tiền"
neural sparse expansion có thể tăng weight cho token liên quan tới "refund", "hoàn tiền"

Điểm mạnh:

  • Vẫn tận dụng được inverted index và sparse scoring.
  • Có khả năng semantic expansion tốt hơn BM25 trong nhiều benchmark.
  • Dễ debug hơn dense một phần vì output vẫn liên quan tới token.

Điểm đổi lại:

  • Indexing và serving phức tạp hơn BM25.
  • Cần model riêng, compute riêng và eval riêng.
  • Sparse vector có thể lớn, cần kiểm soát pruning/top terms.
  • Không nên thêm nếu team chưa có BM25 + dense baseline và query eval set.

Rule thực tế: với RAG v1, bắt đầu bằng BM25 + dense + RRF. Xem SPLADE là bước nâng cấp khi eval chứng minh BM25 là bottleneck và team có năng lực vận hành neural sparse index.

7. Hybrid search mental model

Hybrid search thường chạy song song hai candidate generators:

                         +-> BM25 top 50 -----+
query -> normalize/filter|                    +-> merge/dedupe -> rerank -> context
                         +-> dense top 50 ----+

Mỗi path có vai trò khác nhau:

PathVai tròDạng lỗi hay bù cho path còn lại
BM25Bắt exact keyword, acronym, code, term hiếmDense miss mã lỗi/tên riêng
DenseBắt semantic intent, synonym, paraphraseBM25 miss khi wording khác
RerankerXếp lại candidates theo query-document relevance sâu hơnCả BM25/dense chỉ là candidate generator

Một pipeline gần production:

1. nhận query + auth context
2. normalize query an toàn
3. build mandatory filters: tenant, ACL, index_version, deleted=false
4. chạy BM25 top_n và dense top_n song song
5. merge bằng RRF, không merge raw score trực tiếp
6. deduplicate theo chunk_id hoặc canonical_chunk_id
7. optional rerank top 20-100
8. build context có citation, source, page, section
9. log ranks/scores/latency/debug info đã redact PII
10. evaluate offline và monitor online

Filter quyền phải áp dụng ở cả BM25 path và dense path. Nếu BM25 không filter ACL nhưng vector có filter ACL, RRF merge vẫn có thể đưa chunk restricted vào context. Đây là lỗi security.

8. Reciprocal Rank Fusion

Reciprocal Rank Fusion, viết tắt là RRF, merge nhiều ranking bằng rank thay vì raw score:

rrf_score(doc) = sum(1 / (k + rank_i(doc)))

Trong đó:

  • rank_i(doc) là vị trí của document trong ranking thứ i, bắt đầu từ 1.
  • Nếu document không xuất hiện trong một ranking, ranking đó không đóng góp score.
  • k thường dùng khoảng 60 để làm giảm độ chênh giữa rank rất cao và rank trung bình.

Ví dụ:

DocBM25 rankDense rankÝ nghĩa
A120BM25 rất chắc, vẫn nên giữ
B73Cả hai path đều ủng hộ
C-1Dense bắt semantic tốt, BM25 miss
D2-BM25 bắt exact keyword, dense miss

Vì sao RRF tốt để bắt đầu:

  • Không cần calibrate BM25 score với cosine score.
  • Ổn định khi hai search engine có score scale khác nhau.
  • Dễ implement, dễ explain, dễ debug.
  • Thường là baseline mạnh trước khi tuning weighted fusion.

Khi nào cân nhắc weighted score fusion:

  • Bạn có eval set đủ tốt.
  • Score đã được normalize/calibrate cẩn thận.
  • Bạn cần ưu tiên path cụ thể theo query classifier.
  • Bạn có monitoring để phát hiện regression theo query category.

Nếu chưa có các điều kiện trên, RRF là lựa chọn thực dụng hơn.

9. Query normalization

Query normalization là bước làm query nhất quán với index analyzer, nhưng không được phá hỏng thông tin quan trọng.

Nên làm:

  • Unicode normalize, ví dụ NFC/NFKC tùy pipeline.
  • Trim khoảng trắng, gom nhiều spaces thành một.
  • Lowercase cho phần text thường nếu analyzer cũng lowercase.
  • Chuẩn hóa dấu câu phổ biến nhưng giữ ký tự có nghĩa.
  • Tạo variant có dấu/không dấu nếu corpus và user input bị mix.
  • Map synonym domain bằng dictionary được kiểm soát, ví dụ 2fa -> two factor authentication.
  • Giữ nguyên số, code, SKU, acronym, config key, API name.

Không nên làm quá aggressive:

"C++"       -> "c"       # sai
"S3"        -> "s"       # sai
"HTTP 429"  -> ""        # sai
"P1/P2"     -> "p p"     # sai
"node.js"   -> "node js" # có thể sai nếu corpus giữ "node.js"

Với tiếng Việt, các vấn đề hay gặp:

Vấn đềVí dụCách xử lý
Query không dấu"hoan tien" vs "hoàn tiền"Analyzer hỗ trợ folding hoặc tạo query variants
English mix"reset mat khau 2FA"Giữ acronym/code, normalize phần tiếng Việt
Tên riêng"Phong Ke Toan" vs "Phòng Kế Toán"Dictionary/entity normalization
Dấu câu có nghĩaC++, C#, node.jsTokenizer whitelist pattern

Rule production: analyzer lúc index và analyzer lúc query phải consistent. Mỗi thay đổi analyzer cần reindex hoặc ít nhất chạy lại benchmark.

10. Keyword-heavy vs semantic query

Không phải query nào cũng nên được xử lý giống nhau.

Query typeVí dụPath thường mạnhGhi chú
Keyword-heavyHTTP 429, SLA P1, VAT invoiceBM25Exact token rất quan trọng
Semantic-heavy"tôi muốn lấy lại tiền"DenseUser không dùng đúng wording
Mixed"hoàn tiền gói Pro VAT"HybridCó intent và exact term
No-diacritic Vietnamese"hoan tien goi enterprise"Tùy analyzer + denseCần test riêng
Code/APIquery_points filter MatchAnyBM25 + denseTokenizer phải giữ identifier
Legal/policy"điều kiện nghỉ phép theo điều 12"HybridExact clause + semantic context

Một số hệ thống thêm query classifier:

if query contains many codes/acronyms/numbers:
    increase BM25 top_k or weight
elif query is long natural language:
    increase dense top_k
else:
    use balanced hybrid

Đừng bắt đầu bằng classifier phức tạp. Hãy bắt đầu bằng hybrid balanced, log query category, rồi tối ưu bằng số liệu.

11. Dedupe và chunk selection

Hybrid search dễ trả về nhiều chunk gần giống nhau vì BM25 và dense cùng tìm thấy các chunk cạnh nhau trong một document.

Dedupe nên có nhiều tầng:

TầngKeyMục đích
Exact chunkchunk_idKhông trả một chunk hai lần
Canonical chunkdocument_id + section + chunk_indexTránh duplicate giữa index version nếu có bug
Near-duplicatetext hash hoặc simhashLoại bản copy giống nhau
Document diversitygiới hạn chunk mỗi documentTránh một document chiếm hết context

Trong RAG, top results không chỉ cần relevant, mà còn cần đa dạng đủ để trả lời. Sau RRF, context builder có thể áp dụng policy:

max_chunks_per_document = 2
prefer_latest_document_version = true
require_citation_fields = true
drop_chunks_below_min_score_after_rerank = true

12. Evaluation

Không nên đánh giá retrieval bằng vài câu query tự nghĩ rồi nhìn output. Cần query set có qrels, tức mapping query -> relevant chunk/document.

Query set tối thiểu nên có các category:

semantic
keyword
mixed
acronym
exact_code
no_diacritic
english_mix
short_query
long_question
negative_or_ambiguous

Metrics:

MetricÝ nghĩaKhi dùng
Hit@KCó ít nhất một chunk đúng trong top K khôngDễ hiểu cho RAG
Recall@KLấy được bao nhiêu chunk đúng trong top KKhi nhiều chunk cùng relevant
MRR@KRelevant đầu tiên ở rank mấyĐo ranking top đầu
nDCG@KCó graded relevanceKhi qrels có mức độ đúng
p50/p95/p99 latencyĐộ trễ retrievalProduction SLA
zero-result rateTỷ lệ không có candidateHealth check
ACL leak testKhông trả chunk sai quyềnSecurity gate

Bảng so sánh bắt buộc:

| Config | Hit@5 | Recall@10 | MRR@10 | p95 ms | Ghi chú |
|---|---:|---:|---:|---:|---|
| BM25-only | | | | | |
| Dense-only | | | | | |
| Hybrid RRF | | | | | |
| Hybrid RRF + reranker | | | | | |

Luôn xem metric theo category. Average có thể che lỗi:

Dense-only average tốt nhưng exact_code rất tệ.
BM25-only keyword tốt nhưng semantic rất tệ.
Hybrid average tốt và ít category bị rơi mạnh hơn.

13. Performance và latency

Hybrid search thêm ít nhất hai search calls. Nếu chạy tuần tự, latency có thể tăng đáng kể:

latency_total = normalize + bm25 + dense + merge + rerank + context

Production nên chạy BM25 và dense song song nếu infra cho phép:

latency_total ~= normalize + max(bm25, dense) + merge + rerank + context

Latency budget tham khảo cho RAG v1:

StageBudget tham khảo
Query normalize1-20 ms
Build filters1-5 ms
BM25 search20-120 ms
Dense search20-180 ms
RRF merge + dedupe1-10 ms
Reranker optional100-800 ms
Context builder5-50 ms

Trade-off chính:

Quyết địnhTăng chất lượngTăng cost/latencyGhi chú
Tăng BM25 top_kTốt hơn cho keyword recallNhiều candidate hơnCó thể làm reranker chậm
Tăng dense top_kTốt hơn cho semantic recallVector search và rerank chậm hơnCần benchmark
RRF k nhỏ hơnTop rank ảnh hưởng mạnh hơnCó thể kém ổn địnhTune bằng eval
Thêm rerankerRanking/citation tốt hơnLatency lớn nhấtRerank top 20-100
Query expansionCải thiện query ngắnRisk drift intentCần guardrail
SPLADESparse semantic tốt hơnOps phức tạp hơnKhông phải bước đầu

14. Security và multi-tenancy

Hybrid retrieval có hai path nên dễ có bug phân quyền.

Filter bắt buộc ở cả BM25 và dense:

tenant_id = current_user.tenant_id
AND index_version = active_index_version
AND deleted_at IS NULL
AND acl_roles intersects current_user.roles

Cache key cũng phải chứa:

tenant_id
user_permission_hash
query_normalized
index_version
retrieval_config_version

Log phải đủ debug nhưng không leak PII:

{
  "query_hash": "sha256:...",
  "tenant_id": "company_a",
  "bm25_top_k": 50,
  "dense_top_k": 50,
  "rrf_k": 60,
  "bm25_latency_ms": 42,
  "dense_latency_ms": 65,
  "merge_latency_ms": 2,
  "final_chunk_ids": ["..."],
  "index_version": "rag-index-2026-05-10"
}

Không log raw query hoặc raw chunk nếu dữ liệu có thể chứa thông tin nhạy cảm. Dùng sampling, hashing và redaction.

15. Production readiness

Câu hỏi: "Dùng hybrid search trong production được không?"

Có, hybrid search là một lựa chọn rất hợp lý cho production RAG, đặc biệt khi corpus có tài liệu doanh nghiệp, policy, code, acronym, mã lỗi, tiếng Việt không dấu/có dấu và English mix. Tuy nhiên, chỉ nên gọi là production-ready khi thỏa các điều kiện sau:

  • Có BM25 index và vector index cùng index_version hoặc có cơ chế sync rõ ràng.
  • Analyzer/tokenizer được chọn theo ngôn ngữ corpus và đã test với query thật.
  • Tenant/ACL/deleted/index filters được áp dụng bắt buộc ở cả hai retrieval path.
  • Có qrels và benchmark BM25-only vs dense-only vs hybrid.
  • Có metric quality theo query category, không chỉ average.
  • Có latency budget p95/p99 và load test với top_k/reranker thực tế.
  • Có logging ranks/scores/latency đủ để debug nhưng đã redact PII.
  • Có cache key chứa tenant, permission hash, index version và config version.
  • Có runbook reindex khi đổi analyzer, embedding model, chunking strategy hoặc SPLADE model.
  • Có regression tests chống leak tenant/ACL.

Nếu thiếu eval set và ACL tests, hybrid search vẫn có thể chạy, nhưng chưa nên coi là production-ready.

16. Best practices

  1. Luôn có BM25 baseline trước khi kết luận embedding model tốt.
  2. Với enterprise RAG, thử hybrid trước khi thử vector-only.
  3. Merge bằng RRF trước khi tuning weighted fusion.
  4. Không merge raw BM25 score và cosine score trực tiếp nếu chưa calibrate.
  5. Chạy BM25 và dense song song trên online path.
  6. Filter tenant/ACL ở cả hai path, không filter sau merge.
  7. Dedupe theo chunk_id, rồi kiểm soát diversity theo document_id.
  8. Log rank từ từng path để debug vì sao chunk vào top results.
  9. Đánh giá theo query category: semantic, keyword, mixed, code, no-diacritic.
  10. Reranker là bước sau hybrid, không thay thế candidate generation.
  11. Không normalize mất acronym, số, mã lỗi, SKU, API name.
  12. Re-run eval khi đổi embedding model, analyzer, chunking hoặc RRF config.

17. Checklist tự kiểm tra

  • Tôi giải thích được dense retrieval mạnh và yếu ở đâu.
  • Tôi giải thích được BM25 scoring ở mức trực giác.
  • Tôi biết SPLADE khác BM25 và dense retrieval như thế nào.
  • Tôi biết vì sao hybrid không nên merge raw scores trực tiếp.
  • Tôi implement được RRF.
  • Tôi biết normalize query tiếng Việt mà không làm hỏng code/acronym.
  • Tôi biết thiết kế filter tenant/ACL cho cả BM25 và dense.
  • Tôi biết benchmark BM25-only, dense-only, hybrid và hybrid + reranker.
  • Tôi biết đọc kết quả theo query category.
  • Tôi trả lời được điều kiện production readiness.

18. Câu hỏi ôn tập

  1. Vì sao embedding không thay thế hoàn toàn BM25?
  2. BM25 sẽ thắng dense retrieval trong những query nào?
  3. Dense retrieval sẽ thắng BM25 trong những query nào?
  4. RRF giải quyết vấn đề gì khi merge BM25 và vector search?
  5. Vì sao không nên cộng trực tiếp BM25 score với cosine score?
  6. Query normalization có thể làm sai retrieval như thế nào?
  7. SPLADE phù hợp ở giai đoạn nào của roadmap?
  8. Vì sao filter tenant/ACL sau merge là không đủ?
  9. Khi nào nên thêm reranker sau hybrid?
  10. Những metric nào cần có trước khi thay đổi analyzer hoặc embedding model?

Tài liệu

1. Cheat sheet nhanh

Hybrid search là chiến lược retrieval kết hợp sparse search và dense search, thường merge bằng RRF rồi đưa candidates sang reranker hoặc context builder.

query + auth context
  -> safe normalization
  -> mandatory filters
  -> BM25 top_n    \
                   -> RRF -> dedupe -> optional rerank -> context
  -> dense top_n   /

Ba lỗi production phổ biến:

  1. Chỉ filter ACL ở vector path, quên filter ở BM25 path.
  2. Normalize query làm mất mã lỗi, acronym, SKU hoặc số điều khoản.
  3. Tối ưu theo average metric, không nhìn category như exact_code hoặc no_diacritic.

2. Decision matrix

ContextLựa chọn hợp lýVì sao
Corpus nhỏ, prototype nhanhBM25-only hoặc dense-onlyĐơn giản để có baseline
FAQ nhiều synonym, ít mã lỗi/tên riêngDense-first, vẫn benchmark BM25Semantic intent quan trọng
Enterprise docs, policy, support, developer docsHybrid BM25 + dense + RRFVừa có exact term vừa có semantic
Query có nhiều code/acronym/SKUBM25 top_k cao hơn trong hybridExact token quyết định relevance
Query dài, mô tả tự nhiênDense top_k cao hơn trong hybridSemantic signal nhiều hơn
Citation quality rất quan trọngHybrid + rerankerRRF tạo candidates, reranker xếp lại
BM25 miss nhiều vì synonym nhưng muốn sparse indexCân nhắc SPLADEChỉ sau khi có eval và ops readiness
QPS cao, latency rất chặtHybrid không rerank hoặc rerank nhỏCần parallel search và cache

3. Retrieval config mẫu

retrieval:
  active_index_version: "rag-index-2026-05-10-bge-m3-v2"
  bm25:
    enabled: true
    top_k: 50
    analyzer: "vi_en_code_safe_v1"
  dense:
    enabled: true
    top_k: 50
    embedding_model: "BAAI/bge-m3"
    metric: "cosine"
  fusion:
    strategy: "rrf"
    rrf_k: 60
    final_top_k: 20
    max_chunks_per_document: 2
  reranker:
    enabled: false
    top_k: 30
  security:
    require_tenant_filter: true
    require_acl_filter: true
  cache:
    enabled: true
    ttl_seconds: 300

Config phải được version hóa. Khi đổi top_k, rrf_k, analyzer, embedding model hoặc chunking strategy, nên ghi retrieval_config_version vào log và benchmark.

4. Python reference gần production

Ví dụ dưới đây dùng in-memory BM25 và in-memory dense matrix để dễ chạy trong bài học, nhưng cấu trúc code mô phỏng service thật:

  • AuthContext và filter bắt buộc.
  • Chunk chứa metadata cần cho citation/debug.
  • Có normalizer an toàn, không xóa code/acronym.
  • BM25 và dense retrieval tách thành retriever riêng.
  • Hybrid service chạy hai path, RRF, dedupe và giới hạn diversity.
  • Có metrics Hit@K, Recall@K, MRR@K.

Trong production, bạn thay InMemoryBM25Retriever bằng Elasticsearch/OpenSearch/Postgres full-text và thay InMemoryDenseRetriever bằng Vector DB như pgvector, Qdrant, Milvus, Weaviate hoặc Pinecone. Interface và contract không nên đổi.

4.1 Cài đặt cho local demo

pip install rank-bm25 sentence-transformers numpy

sentence-transformers hỗ trợ SentenceTransformer(...).encode(...) để encode documents và query. Nếu model hỗ trợ cosine search, dùng normalize_embeddings=True hoặc tự normalize trước khi dot product.

4.2 Code reference

from __future__ import annotations

import re
import time
import unicodedata
from dataclasses import dataclass
from typing import Iterable, Mapping, Protocol, Sequence

import numpy as np
from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer


@dataclass(frozen=True)
class AuthContext:
    tenant_id: str
    roles: frozenset[str]
    permission_hash: str


@dataclass(frozen=True)
class Chunk:
    chunk_id: str
    document_id: str
    text: str
    tenant_id: str
    acl_roles: frozenset[str]
    source_uri: str
    page: int | None
    index_version: str
    deleted: bool = False


@dataclass(frozen=True)
class SearchHit:
    chunk: Chunk
    score: float
    rank: int
    source: str


@dataclass(frozen=True)
class HybridHit:
    chunk: Chunk
    rrf_score: float
    final_rank: int
    bm25_rank: int | None
    dense_rank: int | None


@dataclass(frozen=True)
class RetrievalConfig:
    active_index_version: str
    bm25_top_k: int = 50
    dense_top_k: int = 50
    final_top_k: int = 10
    rrf_k: int = 60
    max_chunks_per_document: int = 2


class Retriever(Protocol):
    def search(self, query: str, auth: AuthContext, limit: int) -> list[SearchHit]:
        ...


CODE_SAFE_TOKEN_PATTERN = re.compile(
    r"[A-Za-z]+[+#]{1,2}|[A-Za-z0-9]+(?:[._:/-][A-Za-z0-9]+)+|\w+",
    re.UNICODE,
)


def normalize_query(query: str) -> str:
    normalized = unicodedata.normalize("NFKC", query)
    normalized = re.sub(r"\s+", " ", normalized).strip()
    return normalized


def fold_vietnamese_accents(token: str) -> str:
    token = token.replace("Đ", "D").replace("đ", "d")
    decomposed = unicodedata.normalize("NFD", token)
    return "".join(ch for ch in decomposed if unicodedata.category(ch) != "Mn")


def tokenize_code_safe(text: str) -> list[str]:
    text = normalize_query(text).lower()
    tokens = CODE_SAFE_TOKEN_PATTERN.findall(text)
    expanded: list[str] = []
    for token in tokens:
        expanded.append(token)
        folded = fold_vietnamese_accents(token)
        if folded != token:
            expanded.append(folded)
    return expanded


def is_visible(chunk: Chunk, auth: AuthContext, active_index_version: str) -> bool:
    return (
        chunk.tenant_id == auth.tenant_id
        and chunk.index_version == active_index_version
        and not chunk.deleted
        and bool(chunk.acl_roles.intersection(auth.roles))
    )


class InMemoryBM25Retriever:
    def __init__(self, chunks: Sequence[Chunk], active_index_version: str) -> None:
        self._chunks = list(chunks)
        self._active_index_version = active_index_version
        tokenized_corpus = [tokenize_code_safe(chunk.text) for chunk in self._chunks]
        self._bm25 = BM25Okapi(tokenized_corpus)

    def search(self, query: str, auth: AuthContext, limit: int) -> list[SearchHit]:
        tokens = tokenize_code_safe(query)
        if not tokens:
            return []

        scores = np.asarray(self._bm25.get_scores(tokens), dtype=np.float32)
        order = np.argsort(-scores)
        hits: list[SearchHit] = []

        for idx in order:
            chunk = self._chunks[int(idx)]
            if scores[idx] <= 0:
                continue
            if not is_visible(chunk, auth, self._active_index_version):
                continue
            hits.append(
                SearchHit(
                    chunk=chunk,
                    score=float(scores[idx]),
                    rank=len(hits) + 1,
                    source="bm25",
                )
            )
            if len(hits) >= limit:
                break

        return hits


class InMemoryDenseRetriever:
    def __init__(
        self,
        chunks: Sequence[Chunk],
        model_name: str,
        active_index_version: str,
    ) -> None:
        self._chunks = list(chunks)
        self._model = SentenceTransformer(model_name)
        self._active_index_version = active_index_version
        self._doc_embeddings = self._model.encode(
            [chunk.text for chunk in self._chunks],
            convert_to_numpy=True,
            normalize_embeddings=True,
        )

    def search(self, query: str, auth: AuthContext, limit: int) -> list[SearchHit]:
        query_embedding = self._model.encode(
            [normalize_query(query)],
            convert_to_numpy=True,
            normalize_embeddings=True,
        )[0]
        scores = np.asarray(self._doc_embeddings @ query_embedding, dtype=np.float32)
        order = np.argsort(-scores)
        hits: list[SearchHit] = []

        for idx in order:
            chunk = self._chunks[int(idx)]
            if not is_visible(chunk, auth, self._active_index_version):
                continue
            hits.append(
                SearchHit(
                    chunk=chunk,
                    score=float(scores[idx]),
                    rank=len(hits) + 1,
                    source="dense",
                )
            )
            if len(hits) >= limit:
                break

        return hits


def rrf_fuse(rankings: Sequence[Sequence[SearchHit]], rrf_k: int) -> list[HybridHit]:
    by_chunk: dict[str, Chunk] = {}
    rrf_scores: dict[str, float] = {}
    ranks: dict[str, dict[str, int]] = {}

    for ranking in rankings:
        for hit in ranking:
            chunk_id = hit.chunk.chunk_id
            by_chunk[chunk_id] = hit.chunk
            rrf_scores[chunk_id] = rrf_scores.get(chunk_id, 0.0) + 1.0 / (rrf_k + hit.rank)
            ranks.setdefault(chunk_id, {})[hit.source] = hit.rank

    ordered = sorted(rrf_scores.items(), key=lambda item: item[1], reverse=True)
    return [
        HybridHit(
            chunk=by_chunk[chunk_id],
            rrf_score=score,
            final_rank=i + 1,
            bm25_rank=ranks.get(chunk_id, {}).get("bm25"),
            dense_rank=ranks.get(chunk_id, {}).get("dense"),
        )
        for i, (chunk_id, score) in enumerate(ordered)
    ]


def limit_document_diversity(
    hits: Iterable[HybridHit],
    final_top_k: int,
    max_chunks_per_document: int,
) -> list[HybridHit]:
    per_document_count: dict[str, int] = {}
    selected: list[HybridHit] = []

    for hit in hits:
        count = per_document_count.get(hit.chunk.document_id, 0)
        if count >= max_chunks_per_document:
            continue
        per_document_count[hit.chunk.document_id] = count + 1
        selected.append(
            HybridHit(
                chunk=hit.chunk,
                rrf_score=hit.rrf_score,
                final_rank=len(selected) + 1,
                bm25_rank=hit.bm25_rank,
                dense_rank=hit.dense_rank,
            )
        )
        if len(selected) >= final_top_k:
            break

    return selected


class HybridSearchService:
    def __init__(
        self,
        bm25: Retriever,
        dense: Retriever,
        config: RetrievalConfig,
    ) -> None:
        self._bm25 = bm25
        self._dense = dense
        self._config = config

    def search(self, query: str, auth: AuthContext) -> tuple[list[HybridHit], Mapping[str, float]]:
        normalized_query = normalize_query(query)

        started = time.perf_counter()
        bm25_hits = self._bm25.search(normalized_query, auth, self._config.bm25_top_k)
        bm25_ms = (time.perf_counter() - started) * 1000

        started = time.perf_counter()
        dense_hits = self._dense.search(normalized_query, auth, self._config.dense_top_k)
        dense_ms = (time.perf_counter() - started) * 1000

        started = time.perf_counter()
        fused = rrf_fuse([bm25_hits, dense_hits], self._config.rrf_k)
        final_hits = limit_document_diversity(
            fused,
            final_top_k=self._config.final_top_k,
            max_chunks_per_document=self._config.max_chunks_per_document,
        )
        merge_ms = (time.perf_counter() - started) * 1000

        metrics = {
            "bm25_ms": bm25_ms,
            "dense_ms": dense_ms,
            "merge_ms": merge_ms,
            "bm25_candidates": float(len(bm25_hits)),
            "dense_candidates": float(len(dense_hits)),
            "final_hits": float(len(final_hits)),
        }
        return final_hits, metrics

Trong online service thật, BM25 và dense nên chạy song song bằng async IO hoặc thread pool vì hai calls độc lập. Demo trên chạy tuần tự để dễ đọc.

4.3 Dataset mẫu

CHUNKS = [
    Chunk(
        chunk_id="a:refund:001",
        document_id="refund_policy",
        text="Khách hàng có thể yêu cầu hoàn tiền trong 7 ngày cho gói Pro.",
        tenant_id="company_a",
        acl_roles=frozenset({"employee", "support"}),
        source_uri="s3://company-a/policy/refund.pdf",
        page=1,
        index_version="dev-index-v1",
    ),
    Chunk(
        chunk_id="a:invoice:001",
        document_id="invoice_vat",
        text="Để xuất hóa đơn VAT, cần cung cấp tên công ty và mã số thuế.",
        tenant_id="company_a",
        acl_roles=frozenset({"employee", "finance"}),
        source_uri="s3://company-a/finance/vat.pdf",
        page=2,
        index_version="dev-index-v1",
    ),
    Chunk(
        chunk_id="a:sla:001",
        document_id="sla_enterprise",
        text="Gói Enterprise có SLA uptime 99.9% và hỗ trợ P1 trong 2 giờ.",
        tenant_id="company_a",
        acl_roles=frozenset({"support"}),
        source_uri="s3://company-a/support/sla.pdf",
        page=4,
        index_version="dev-index-v1",
    ),
    Chunk(
        chunk_id="a:security:001",
        document_id="security_2fa",
        text="Tài khoản admin bắt buộc bật xác thực hai lớp 2FA.",
        tenant_id="company_a",
        acl_roles=frozenset({"admin"}),
        source_uri="s3://company-a/security/2fa.pdf",
        page=3,
        index_version="dev-index-v1",
    ),
    Chunk(
        chunk_id="a:api:001",
        document_id="api_rate_limit",
        text="API giới hạn 600 request mỗi phút. Vượt giới hạn trả về HTTP 429.",
        tenant_id="company_a",
        acl_roles=frozenset({"developer", "support"}),
        source_uri="s3://company-a/dev/api-rate-limit.md",
        page=None,
        index_version="dev-index-v1",
    ),
    Chunk(
        chunk_id="b:refund:001",
        document_id="refund_policy",
        text="Khách hàng công ty B có thể hoàn tiền trong 14 ngày.",
        tenant_id="company_b",
        acl_roles=frozenset({"employee", "support"}),
        source_uri="s3://company-b/policy/refund.pdf",
        page=1,
        index_version="dev-index-v1",
    ),
]

config = RetrievalConfig(active_index_version="dev-index-v1", final_top_k=5)
auth = AuthContext(
    tenant_id="company_a",
    roles=frozenset({"employee", "support", "developer"}),
    permission_hash="demo-permission-hash",
)

bm25 = InMemoryBM25Retriever(CHUNKS, active_index_version=config.active_index_version)
dense = InMemoryDenseRetriever(
    CHUNKS,
    model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
    active_index_version=config.active_index_version,
)
service = HybridSearchService(bm25=bm25, dense=dense, config=config)

for query in ["tôi muốn lấy lại tiền", "HTTP 429", "SLA enterprise P1", "xuat VAT"]:
    hits, metrics = service.search(query, auth)
    print(query, metrics)
    for hit in hits:
        print(
            hit.final_rank,
            hit.chunk.document_id,
            hit.rrf_score,
            {"bm25_rank": hit.bm25_rank, "dense_rank": hit.dense_rank},
        )

5. Metrics helper

def hit_at_k(results: Sequence[str], relevant: set[str], k: int) -> float:
    return float(any(doc_id in relevant for doc_id in results[:k]))


def recall_at_k(results: Sequence[str], relevant: set[str], k: int) -> float:
    if not relevant:
        return 0.0
    return len(set(results[:k]).intersection(relevant)) / len(relevant)


def mrr_at_k(results: Sequence[str], relevant: set[str], k: int) -> float:
    for idx, doc_id in enumerate(results[:k], start=1):
        if doc_id in relevant:
            return 1.0 / idx
    return 0.0


def evaluate(
    predictions: Mapping[str, Sequence[str]],
    qrels: Mapping[str, set[str]],
    k: int,
) -> dict[str, float]:
    query_ids = list(qrels)
    return {
        f"hit@{k}": sum(hit_at_k(predictions[qid], qrels[qid], k) for qid in query_ids) / len(query_ids),
        f"recall@{k}": sum(recall_at_k(predictions[qid], qrels[qid], k) for qid in query_ids) / len(query_ids),
        f"mrr@{k}": sum(mrr_at_k(predictions[qid], qrels[qid], k) for qid in query_ids) / len(query_ids),
    }

6. Benchmark report template

## Retrieval Benchmark

Corpus:
- Chunks:
- Documents:
- Languages:
- Index version:
- Embedding model:
- BM25 analyzer:

Query set:
- Total queries:
- semantic:
- keyword:
- mixed:
- exact_code:
- no_diacritic:
- english_mix:

| Config | Hit@5 | Recall@10 | MRR@10 | p95 ms | p99 ms | Notes |
|---|---:|---:|---:|---:|---:|---|
| BM25-only | | | | | | |
| Dense-only | | | | | | |
| Hybrid RRF | | | | | | |
| Hybrid RRF + reranker | | | | | | |

Findings:
1.
2.
3.

Decision:
- Roll out:
- Need more eval:
- Config:
- Risks:

7. Logging checklist

Log các field này ở retrieval layer:

  • request_id, tenant_id, permission_hash.
  • query_hash, không bắt buộc log raw query.
  • index_version, retrieval_config_version.
  • bm25_top_k, dense_top_k, rrf_k, final_top_k.
  • bm25_latency_ms, dense_latency_ms, merge_latency_ms, rerank_latency_ms.
  • bm25_candidate_count, dense_candidate_count, final_hit_count.
  • Top chunk ids, document ids, source ids.
  • Rank từ từng path: bm25_rank, dense_rank, final_rank.
  • Filter summary: tenant, ACL mode, deleted filter, document version.
  • Error và timeout theo từng backend.

Không log:

  • Raw chunk text chứa PII.
  • Raw user query nếu chưa có policy redaction.
  • ACL list đầy đủ nếu có thể lộ cấu trúc quyền nhạy cảm.

8. Runbook: thay đổi analyzer

Thay analyzer là thay behavior của BM25 index. Không coi đây là config nhỏ.

  1. Tạo analyzer version mới, ví dụ vi_en_code_safe_v2.
  2. Reindex BM25 vào index mới hoặc shadow index.
  3. Chạy benchmark theo query category.
  4. So sánh đặc biệt nhóm no_diacritic, exact_code, english_mix.
  5. Chạy regression tests cho C++, C#, S3, HTTP 429, VAT, P1.
  6. Shadow traffic nếu hệ thống quan trọng.
  7. Switch active index bằng feature flag/config.
  8. Giữ index cũ trong retention window để rollback.

9. Runbook: đổi embedding model

  1. Tạo new_index_version.
  2. Embed lại toàn bộ chunks bằng model mới.
  3. Build vector index mới với dimension/metric đúng.
  4. Đảm bảo BM25 index và vector index cùng active version hoặc có mapping version rõ ràng.
  5. Chạy benchmark dense-only và hybrid.
  6. Chạy load test vì dimension/model mới có thể đổi latency.
  7. Chạy ACL regression tests.
  8. Switch bằng feature flag.
  9. Monitor zero-result rate, answer citation quality và user feedback.

10. Debug playbook

Khi user báo "RAG trả lời sai", kiểm tra theo thứ tự:

  1. Query có vào đúng tenant/index version không?
  2. Chunk đúng có tồn tại trong corpus không?
  3. Chunk đúng có bị deleted hoặc ACL filter loại không?
  4. BM25 rank của chunk đúng là bao nhiêu?
  5. Dense rank của chunk đúng là bao nhiêu?
  6. RRF có merge chunk đúng vào final candidates không?
  7. Dedupe/diversity policy có loại chunk đúng không?
  8. Reranker có đẩy chunk đúng xuống thấp không?
  9. Context builder có cắt mất đoạn chứa answer không?
  10. LLM có dùng citation đúng nhưng tổng hợp sai không?

Nếu chunk đúng không nằm trong BM25 lẫn dense top 100, lỗi nằm ở ingestion/chunking/analyzer/embedding. Nếu chunk đúng có trong candidates nhưng không vào context, lỗi nằm ở fusion/rerank/context builder.

11. Production readiness checklist

  • Có BM25 baseline, dense baseline và hybrid benchmark.
  • Có query set tagged theo category.
  • Có qrels cho top business workflows.
  • Có analyzer phù hợp tiếng Việt, English mix và code token.
  • Có vector index versioned theo embedding model/chunking strategy.
  • Có mandatory tenant/ACL/deleted/index filters trong cả hai path.
  • Có regression tests chống cross-tenant leak.
  • Có RRF config versioned.
  • Có logging rank/latency/candidate count đủ debug.
  • Có p95/p99 latency SLO và load test.
  • Có cache key chứa tenant, permission hash, index version và config version.
  • Có runbook reindex/rollback.
  • Có owner theo dõi quality metric sau release.

12. Quiz ngắn

  1. Tại sao RRF ổn định hơn weighted score fusion khi mới bắt đầu?
  2. Nếu query HTTP 429 không ra đúng document, bạn kiểm tra analyzer hay embedding trước?
  3. Vì sao cần benchmark theo category no_diacritic?
  4. Khi nào SPLADE đáng để thử?
  5. Vì sao cache key phải chứa permission_hash?

Bài tập

Mục tiêu

Sau bài tập này bạn sẽ tự build một mini hybrid retriever có:

  • BM25 search top-k.
  • Dense vector search top-k.
  • Merge bằng Reciprocal Rank Fusion.
  • Query normalization an toàn cho tiếng Việt, acronym và code.
  • Evaluation bằng Hit@K, Recall@K và MRR@K.
  • Báo cáo so sánh BM25-only, dense-only và hybrid.

Thời lượng đề xuất: 120-180 phút.

1. Chuẩn bị

Yêu cầu:

  • Python 3.10+.
  • Máy có thể tải model từ Hugging Face nếu dùng sentence-transformers.

Cài đặt:

python -m venv .venv
source .venv/bin/activate
pip install rank-bm25 sentence-transformers numpy pandas pytest

Nếu máy yếu hoặc không muốn tải model, bạn vẫn có thể hoàn thành phần BM25, RRF và metrics trước. Dense path có thể thay bằng vector giả lập để hiểu pipeline.

2. Dataset mẫu

Tạo file day36_hybrid_demo.py và bắt đầu với dataset sau:

DOCS = [
    {
        "id": "refund_policy",
        "tenant_id": "company_a",
        "roles": ["employee", "support"],
        "text": "Khách hàng có thể yêu cầu hoàn tiền trong 7 ngày cho gói Pro.",
    },
    {
        "id": "invoice_vat",
        "tenant_id": "company_a",
        "roles": ["employee", "finance"],
        "text": "Để xuất hóa đơn VAT, cần cung cấp tên công ty và mã số thuế.",
    },
    {
        "id": "sla_enterprise",
        "tenant_id": "company_a",
        "roles": ["support"],
        "text": "Gói Enterprise có SLA uptime 99.9% và hỗ trợ P1 trong 2 giờ.",
    },
    {
        "id": "password_reset",
        "tenant_id": "company_a",
        "roles": ["employee", "support"],
        "text": "Người dùng có thể reset mật khẩu bằng email đăng ký.",
    },
    {
        "id": "security_2fa",
        "tenant_id": "company_a",
        "roles": ["admin"],
        "text": "Tài khoản admin bắt buộc bật xác thực hai lớp 2FA.",
    },
    {
        "id": "api_rate_limit",
        "tenant_id": "company_a",
        "roles": ["developer", "support"],
        "text": "API giới hạn 600 request mỗi phút. Vượt giới hạn trả về HTTP 429.",
    },
    {
        "id": "refund_policy_b",
        "tenant_id": "company_b",
        "roles": ["employee", "support"],
        "text": "Khách hàng công ty B có thể yêu cầu hoàn tiền trong 14 ngày.",
    },
]

QUERIES = [
    {
        "id": "q_semantic_refund",
        "query": "tôi muốn lấy lại tiền gói Pro",
        "category": "semantic",
        "relevant": {"refund_policy"},
    },
    {
        "id": "q_keyword_vat",
        "query": "xuat VAT cho cong ty",
        "category": "keyword_no_diacritic",
        "relevant": {"invoice_vat"},
    },
    {
        "id": "q_keyword_sla",
        "query": "SLA enterprise P1",
        "category": "keyword",
        "relevant": {"sla_enterprise"},
    },
    {
        "id": "q_code_429",
        "query": "lỗi 429 là gì",
        "category": "exact_code",
        "relevant": {"api_rate_limit"},
    },
    {
        "id": "q_semantic_password",
        "query": "quên password thì làm sao vào lại tài khoản",
        "category": "mixed",
        "relevant": {"password_reset"},
    },
]

3. Implement tokenizer và normalizer

Yêu cầu:

  • Không xóa số.
  • Không làm hỏng HTTP 429, 2FA, C++, node.js.
  • Có lowercase cho token text thường.
  • Có thể xử lý query không dấu ở mức cơ bản bằng accent-folding có kiểm soát.

Gợi ý:

import re
import unicodedata


TOKEN_PATTERN = re.compile(
    r"[A-Za-z]+[+#]{1,2}|[A-Za-z0-9]+(?:[._:/-][A-Za-z0-9]+)+|\w+",
    re.UNICODE,
)


def normalize_text(text: str) -> str:
    text = unicodedata.normalize("NFKC", text)
    text = re.sub(r"\s+", " ", text).strip()
    return text


def fold_vietnamese_accents(token: str) -> str:
    token = token.replace("Đ", "D").replace("đ", "d")
    decomposed = unicodedata.normalize("NFD", token)
    return "".join(ch for ch in decomposed if unicodedata.category(ch) != "Mn")


def tokenize(text: str) -> list[str]:
    tokens = TOKEN_PATTERN.findall(normalize_text(text).lower())
    expanded: list[str] = []
    for token in tokens:
        expanded.append(token)
        folded = fold_vietnamese_accents(token)
        if folded != token:
            expanded.append(folded)
    return expanded

Thử nhanh:

assert "429" in tokenize("HTTP 429")
assert "2fa" in tokenize("Bật 2FA")
assert "bat" in tokenize("Bật 2FA")
assert tokenize("C++") != ["c"]
import numpy as np
from rank_bm25 import BM25Okapi


class BM25Search:
    def __init__(self, docs: list[dict]) -> None:
        self.docs = docs
        self.index = BM25Okapi([tokenize(doc["text"]) for doc in docs])

    def search(self, query: str, tenant_id: str, roles: set[str], k: int) -> list[tuple[str, float]]:
        scores = np.asarray(self.index.get_scores(tokenize(query)), dtype=np.float32)
        order = np.argsort(-scores)
        results: list[tuple[str, float]] = []

        for idx in order:
            doc = self.docs[int(idx)]
            if scores[idx] <= 0:
                continue
            if doc["tenant_id"] != tenant_id:
                continue
            if not roles.intersection(doc["roles"]):
                continue
            results.append((doc["id"], float(scores[idx])))
            if len(results) >= k:
                break

        return results
from sentence_transformers import SentenceTransformer


class DenseSearch:
    def __init__(self, docs: list[dict], model_name: str) -> None:
        self.docs = docs
        self.model = SentenceTransformer(model_name)
        self.doc_embeddings = self.model.encode(
            [doc["text"] for doc in docs],
            convert_to_numpy=True,
            normalize_embeddings=True,
        )

    def search(self, query: str, tenant_id: str, roles: set[str], k: int) -> list[tuple[str, float]]:
        query_embedding = self.model.encode(
            [normalize_text(query)],
            convert_to_numpy=True,
            normalize_embeddings=True,
        )[0]
        scores = np.asarray(self.doc_embeddings @ query_embedding, dtype=np.float32)
        order = np.argsort(-scores)
        results: list[tuple[str, float]] = []

        for idx in order:
            doc = self.docs[int(idx)]
            if doc["tenant_id"] != tenant_id:
                continue
            if not roles.intersection(doc["roles"]):
                continue
            results.append((doc["id"], float(scores[idx])))
            if len(results) >= k:
                break

        return results

Model gợi ý để chạy local:

MODEL_NAME = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"

Với hệ thống thật, chọn model bằng benchmark domain, không chọn chỉ vì model nổi tiếng.

6. Implement RRF

def rrf_merge(rankings: list[list[tuple[str, float]]], rrf_k: int = 60) -> list[tuple[str, float]]:
    scores: dict[str, float] = {}
    for ranking in rankings:
        for rank, (doc_id, _) in enumerate(ranking, start=1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (rrf_k + rank)
    return sorted(scores.items(), key=lambda item: item[1], reverse=True)

Không dùng:

final_score = bm25_score + cosine_score

Lý do: BM25 score và cosine score không cùng scale.

7. Implement metrics

def hit_at_k(results: list[str], relevant: set[str], k: int) -> float:
    return float(any(doc_id in relevant for doc_id in results[:k]))


def recall_at_k(results: list[str], relevant: set[str], k: int) -> float:
    return len(set(results[:k]).intersection(relevant)) / len(relevant)


def mrr_at_k(results: list[str], relevant: set[str], k: int) -> float:
    for rank, doc_id in enumerate(results[:k], start=1):
        if doc_id in relevant:
            return 1.0 / rank
    return 0.0

Chạy benchmark:

def evaluate_system(name: str, predictions: dict[str, list[str]], k: int = 5) -> dict[str, float]:
    by_id = {item["id"]: item for item in QUERIES}
    hit = []
    recall = []
    mrr = []
    for query_id, ranked_doc_ids in predictions.items():
        relevant = by_id[query_id]["relevant"]
        hit.append(hit_at_k(ranked_doc_ids, relevant, k))
        recall.append(recall_at_k(ranked_doc_ids, relevant, k))
        mrr.append(mrr_at_k(ranked_doc_ids, relevant, k))
    return {
        "system": name,
        f"hit@{k}": sum(hit) / len(hit),
        f"recall@{k}": sum(recall) / len(recall),
        f"mrr@{k}": sum(mrr) / len(mrr),
    }

8. Chạy so sánh ba hệ thống

bm25 = BM25Search(DOCS)
dense = DenseSearch(DOCS, MODEL_NAME)

tenant_id = "company_a"
roles = {"employee", "support", "developer"}

bm25_predictions: dict[str, list[str]] = {}
dense_predictions: dict[str, list[str]] = {}
hybrid_predictions: dict[str, list[str]] = {}

for item in QUERIES:
    bm25_hits = bm25.search(item["query"], tenant_id, roles, k=5)
    dense_hits = dense.search(item["query"], tenant_id, roles, k=5)
    hybrid_hits = rrf_merge([bm25_hits, dense_hits], rrf_k=60)[:5]

    bm25_predictions[item["id"]] = [doc_id for doc_id, _ in bm25_hits]
    dense_predictions[item["id"]] = [doc_id for doc_id, _ in dense_hits]
    hybrid_predictions[item["id"]] = [doc_id for doc_id, _ in hybrid_hits]

    print("\\nQUERY:", item["query"], "|", item["category"])
    print("expected:", item["relevant"])
    print("bm25:", bm25_predictions[item["id"]])
    print("dense:", dense_predictions[item["id"]])
    print("hybrid:", hybrid_predictions[item["id"]])

print(evaluate_system("bm25", bm25_predictions, k=5))
print(evaluate_system("dense", dense_predictions, k=5))
print(evaluate_system("hybrid", hybrid_predictions, k=5))

9. Bài tập bắt buộc

  1. Thêm ít nhất 15 documents nữa.
  2. Thêm ít nhất 20 queries, chia đều cho các category:
    • semantic
    • keyword
    • mixed
    • exact_code
    • no_diacritic
    • english_mix
  3. Báo cáo BM25-only vs dense-only vs hybrid.
  4. Ghi ra 3 query BM25 thắng dense.
  5. Ghi ra 3 query dense thắng BM25.
  6. Ghi ra 3 query hybrid tốt hơn từng path riêng lẻ.
  7. Thử bm25_top_kdense_top_k lần lượt là 3, 5, 10, 20.
  8. Thử rrf_k là 10, 30, 60, 100.
  9. Kiểm tra tenant/ACL: user company_a không được thấy document company_b.
  10. Viết kết luận production readiness.

10. Test bằng pytest

Tạo file test_day36_hybrid.py.

def test_company_a_cannot_see_company_b():
    bm25 = BM25Search(DOCS)
    hits = bm25.search("hoàn tiền", tenant_id="company_a", roles={"employee"}, k=20)
    assert hits
    assert "refund_policy_b" not in [doc_id for doc_id, _ in hits]


def test_employee_cannot_see_admin_2fa_doc():
    bm25 = BM25Search(DOCS)
    hits = bm25.search("2FA admin", tenant_id="company_a", roles={"employee"}, k=20)
    assert "security_2fa" not in [doc_id for doc_id, _ in hits]


def test_rrf_keeps_dense_only_candidate():
    bm25_hits = [("doc_a", 10.0)]
    dense_hits = [("doc_b", 0.9)]
    fused = [doc_id for doc_id, _ in rrf_merge([bm25_hits, dense_hits])]
    assert "doc_a" in fused
    assert "doc_b" in fused

11. Mẫu báo cáo cần nộp

# Day 36 Hybrid Retrieval Report

## Dataset

- Documents:
- Queries:
- Query categories:
- Embedding model:
- Analyzer/tokenizer:

## Metrics

| System | Hit@5 | Recall@5 | MRR@5 | Notes |
|---|---:|---:|---:|---|
| BM25-only | | | | |
| Dense-only | | | | |
| Hybrid RRF | | | | |

## Findings

1. BM25 mạnh ở:
2. Dense mạnh ở:
3. Hybrid cải thiện ở:
4. Query normalization làm sai ở:
5. ACL/filter test:

## Production Readiness

Hybrid search dùng được trong production không?

Trả lời:

Điều kiện còn thiếu trước production:

- Golden set tối thiểu 30-50 query có tag `keyword`, `semantic`, `no-diacritic`, `code-heavy``permission`.
- Tenant/ACL/deleted/index filters được enforce giống nhau ở BM25 path và dense path.
- Có p95/p99 latency, query timeout, fallback khi một retriever lỗi và regression test cho analyzer/embedding/index version.

12. Câu hỏi tự luận

  1. Nếu query HTTP 429 không retrieve được api_rate_limit, bạn debug theo thứ tự nào?
  2. Nếu query "tôi muốn lấy lại tiền" BM25 miss nhưng dense đúng, bạn có nên thêm synonym dictionary không?
  3. Vì sao query không dấu cần được đánh giá riêng ở corpus tiếng Việt?
  4. Nếu hybrid tăng Hit@5 nhưng p95 latency vượt SLA, bạn tối ưu gì trước?
  5. Khi nào bạn thêm reranker vào sau RRF?
  6. Khi nào bạn cân nhắc SPLADE thay vì BM25?

13. Tiêu chí hoàn thành

  • Code chạy được end-to-end.
  • BM25, dense và hybrid đều có output riêng.
  • RRF không dùng raw score fusion.
  • Metrics được tính tự động.
  • Có ít nhất 20 queries có qrels.
  • Có category analysis.
  • Có test tenant/ACL.
  • Có kết luận production readiness rõ ràng.