- Published on
Day 36: Hybrid Search Production
- Authors

- Name
- Trần Mạnh Thắng
- @TranManhThang96
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:
| Query | Chunk đúng | Vì 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ạng | Mô tả | Khi dùng |
|---|---|---|
| Boolean search | Match token/phrase/filter đơn giản | Admin search, debug, filter chính xác |
| TF-IDF | Term frequency + inverse document frequency | Baseline học thuật, corpus nhỏ |
| BM25 | Ranking keyword mạnh, có saturation và length normalization | Baseline production phổ biến |
| Neural sparse, ví dụ SPLADE | Dùng model để tạo token weight và query/document expansion | Khi 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ĩa | Tác động |
|---|---|---|
tf | Term frequency trong document | Term xuất hiện nhiều hơn thì score tăng |
IDF | Term hiếm trong corpus có trọng số cao hơn | VAT thường quan trọng hơn là |
| ` | D | / avgdl` |
k1 | Điều khiển saturation của term frequency | Cao 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ài | 0 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:
| Path | Vai trò | Dạng lỗi hay bù cho path còn lại |
|---|---|---|
| BM25 | Bắt exact keyword, acronym, code, term hiếm | Dense miss mã lỗi/tên riêng |
| Dense | Bắt semantic intent, synonym, paraphrase | BM25 miss khi wording khác |
| Reranker | Xếp lại candidates theo query-document relevance sâu hơn | Cả 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.
kthườ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ụ:
| Doc | BM25 rank | Dense rank | Ý nghĩa |
|---|---|---|---|
| A | 1 | 20 | BM25 rất chắc, vẫn nên giữ |
| B | 7 | 3 | Cả hai path đều ủng hộ |
| C | - | 1 | Dense bắt semantic tốt, BM25 miss |
| D | 2 | - | 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ĩa | C++, C#, node.js | Tokenizer 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 type | Ví dụ | Path thường mạnh | Ghi chú |
|---|---|---|---|
| Keyword-heavy | HTTP 429, SLA P1, VAT invoice | BM25 | Exact token rất quan trọng |
| Semantic-heavy | "tôi muốn lấy lại tiền" | Dense | User không dùng đúng wording |
| Mixed | "hoàn tiền gói Pro VAT" | Hybrid | Có intent và exact term |
| No-diacritic Vietnamese | "hoan tien goi enterprise" | Tùy analyzer + dense | Cần test riêng |
| Code/API | query_points filter MatchAny | BM25 + dense | Tokenizer phải giữ identifier |
| Legal/policy | "điều kiện nghỉ phép theo điều 12" | Hybrid | Exact 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ầng | Key | Mục đích |
|---|---|---|
| Exact chunk | chunk_id | Không trả một chunk hai lần |
| Canonical chunk | document_id + section + chunk_index | Tránh duplicate giữa index version nếu có bug |
| Near-duplicate | text hash hoặc simhash | Loại bản copy giống nhau |
| Document diversity | giới hạn chunk mỗi document | Trá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ĩa | Khi dùng |
|---|---|---|
| Hit@K | Có ít nhất một chunk đúng trong top K không | Dễ hiểu cho RAG |
| Recall@K | Lấy được bao nhiêu chunk đúng trong top K | Khi nhiều chunk cùng relevant |
| MRR@K | Relevant đầu tiên ở rank mấy | Đo ranking top đầu |
| nDCG@K | Có graded relevance | Khi qrels có mức độ đúng |
| p50/p95/p99 latency | Độ trễ retrieval | Production SLA |
| zero-result rate | Tỷ lệ không có candidate | Health check |
| ACL leak test | Không trả chunk sai quyền | Security 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:
| Stage | Budget tham khảo |
|---|---|
| Query normalize | 1-20 ms |
| Build filters | 1-5 ms |
| BM25 search | 20-120 ms |
| Dense search | 20-180 ms |
| RRF merge + dedupe | 1-10 ms |
| Reranker optional | 100-800 ms |
| Context builder | 5-50 ms |
Trade-off chính:
| Quyết định | Tăng chất lượng | Tăng cost/latency | Ghi chú |
|---|---|---|---|
| Tăng BM25 top_k | Tốt hơn cho keyword recall | Nhiều candidate hơn | Có thể làm reranker chậm |
| Tăng dense top_k | Tốt hơn cho semantic recall | Vector search và rerank chậm hơn | Cần benchmark |
| RRF k nhỏ hơn | Top rank ảnh hưởng mạnh hơn | Có thể kém ổn định | Tune bằng eval |
| Thêm reranker | Ranking/citation tốt hơn | Latency lớn nhất | Rerank top 20-100 |
| Query expansion | Cải thiện query ngắn | Risk drift intent | Cần guardrail |
| SPLADE | Sparse semantic tốt hơn | Ops phức tạp hơn | Khô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_versionhoặ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
- Luôn có BM25 baseline trước khi kết luận embedding model tốt.
- Với enterprise RAG, thử hybrid trước khi thử vector-only.
- Merge bằng RRF trước khi tuning weighted fusion.
- Không merge raw BM25 score và cosine score trực tiếp nếu chưa calibrate.
- Chạy BM25 và dense song song trên online path.
- Filter tenant/ACL ở cả hai path, không filter sau merge.
- Dedupe theo
chunk_id, rồi kiểm soát diversity theodocument_id. - Log rank từ từng path để debug vì sao chunk vào top results.
- Đánh giá theo query category: semantic, keyword, mixed, code, no-diacritic.
- Reranker là bước sau hybrid, không thay thế candidate generation.
- Không normalize mất acronym, số, mã lỗi, SKU, API name.
- 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
- Vì sao embedding không thay thế hoàn toàn BM25?
- BM25 sẽ thắng dense retrieval trong những query nào?
- Dense retrieval sẽ thắng BM25 trong những query nào?
- RRF giải quyết vấn đề gì khi merge BM25 và vector search?
- Vì sao không nên cộng trực tiếp BM25 score với cosine score?
- Query normalization có thể làm sai retrieval như thế nào?
- SPLADE phù hợp ở giai đoạn nào của roadmap?
- Vì sao filter tenant/ACL sau merge là không đủ?
- Khi nào nên thêm reranker sau hybrid?
- 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:
- Chỉ filter ACL ở vector path, quên filter ở BM25 path.
- Normalize query làm mất mã lỗi, acronym, SKU hoặc số điều khoản.
- Tối ưu theo average metric, không nhìn category như
exact_codehoặcno_diacritic.
2. Decision matrix
| Context | Lựa chọn hợp lý | Vì sao |
|---|---|---|
| Corpus nhỏ, prototype nhanh | BM25-only hoặc dense-only | Đơn giản để có baseline |
| FAQ nhiều synonym, ít mã lỗi/tên riêng | Dense-first, vẫn benchmark BM25 | Semantic intent quan trọng |
| Enterprise docs, policy, support, developer docs | Hybrid BM25 + dense + RRF | Vừa có exact term vừa có semantic |
| Query có nhiều code/acronym/SKU | BM25 top_k cao hơn trong hybrid | Exact token quyết định relevance |
| Query dài, mô tả tự nhiên | Dense top_k cao hơn trong hybrid | Semantic signal nhiều hơn |
| Citation quality rất quan trọng | Hybrid + reranker | RRF tạo candidates, reranker xếp lại |
| BM25 miss nhiều vì synonym nhưng muốn sparse index | Cân nhắc SPLADE | Chỉ sau khi có eval và ops readiness |
| QPS cao, latency rất chặt | Hybrid 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:
- Có
AuthContextvà filter bắt buộc. - Có
Chunkchứ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ỏ.
- Tạo analyzer version mới, ví dụ
vi_en_code_safe_v2. - Reindex BM25 vào index mới hoặc shadow index.
- Chạy benchmark theo query category.
- So sánh đặc biệt nhóm
no_diacritic,exact_code,english_mix. - Chạy regression tests cho
C++,C#,S3,HTTP 429,VAT,P1. - Shadow traffic nếu hệ thống quan trọng.
- Switch active index bằng feature flag/config.
- Giữ index cũ trong retention window để rollback.
9. Runbook: đổi embedding model
- Tạo
new_index_version. - Embed lại toàn bộ chunks bằng model mới.
- Build vector index mới với dimension/metric đúng.
- Đảm bảo BM25 index và vector index cùng active version hoặc có mapping version rõ ràng.
- Chạy benchmark dense-only và hybrid.
- Chạy load test vì dimension/model mới có thể đổi latency.
- Chạy ACL regression tests.
- Switch bằng feature flag.
- 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ự:
- Query có vào đúng tenant/index version không?
- Chunk đúng có tồn tại trong corpus không?
- Chunk đúng có bị
deletedhoặc ACL filter loại không? - BM25 rank của chunk đúng là bao nhiêu?
- Dense rank của chunk đúng là bao nhiêu?
- RRF có merge chunk đúng vào final candidates không?
- Dedupe/diversity policy có loại chunk đúng không?
- Reranker có đẩy chunk đúng xuống thấp không?
- Context builder có cắt mất đoạn chứa answer không?
- 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
- Tại sao RRF ổn định hơn weighted score fusion khi mới bắt đầu?
- Nếu query
HTTP 429không ra đúng document, bạn kiểm tra analyzer hay embedding trước? - Vì sao cần benchmark theo category
no_diacritic? - Khi nào SPLADE đáng để thử?
- 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"]
4. Implement BM25 search
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
5. Implement dense search
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
- Thêm ít nhất 15 documents nữa.
- Thêm ít nhất 20 queries, chia đều cho các category:
semantickeywordmixedexact_codeno_diacriticenglish_mix
- Báo cáo BM25-only vs dense-only vs hybrid.
- Ghi ra 3 query BM25 thắng dense.
- Ghi ra 3 query dense thắng BM25.
- Ghi ra 3 query hybrid tốt hơn từng path riêng lẻ.
- Thử
bm25_top_kvàdense_top_klần lượt là 3, 5, 10, 20. - Thử
rrf_klà 10, 30, 60, 100. - Kiểm tra tenant/ACL: user
company_akhông được thấy documentcompany_b. - 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` và `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
- Nếu query
HTTP 429không retrieve đượcapi_rate_limit, bạn debug theo thứ tự nào? - 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?
- Vì sao query không dấu cần được đánh giá riêng ở corpus tiếng Việt?
- Nếu hybrid tăng Hit@5 nhưng p95 latency vượt SLA, bạn tối ưu gì trước?
- Khi nào bạn thêm reranker vào sau RRF?
- 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.