Danh sách bài viết

Bài 39: Similarity search và top-K retrieval

Sau khi đã embed và lưu chunk vào vector DB (Bài 36-38), bước tiếp theo trong phase Query là similarity search: tìm các vector gần với query nhất rồi trả về top-K. Bài này đi qua ba distance metric thông dụng (cosine, Euclidean, dot product) với công thức và case dùng, phân biệt exact KNN với approximate nearest neighbor (ANN), các thuật toán index ANN (HNSW, IVF, ScaNN, Product Quantization), cách chọn K và distance threshold, metadata filtering, hybrid search dense + sparse với BM25, Reciprocal Rank Fusion (RRF), Maximal Marginal Relevance (MMR) để giảm trùng lặp, multi-vector search, code ChromaDB top-K, code NumPy thuần manual cosine + top-K, hybrid BM25 + vector + RRF, ba metric đánh giá retrieval (Recall@K, MRR, NDCG), pitfall hay gặp và bài tập.

25/05/2026
14 phút đọc
3 lượt xem
1

Mục tiêu bài học

Sau bài này, bạn cần:

  • Viết được công thức của cosine similarity, Euclidean distance và dot product, biết khi nào dùng metric nào.
  • Phân biệt exact KNN và ANN, hiểu trade-off accuracy vs speed.
  • Biết HNSW / IVF / ScaNN / PQ làm gì, parameter chính của mỗi cái.
  • Chọn được K phù hợp và áp distance threshold để loại chunk không liên quan.
  • Implement được hybrid search: dense (vector) + sparse (BM25) gộp bằng RRF.
  • Implement được MMR để giảm trùng lặp trong top-K.
  • Đo được retrieval bằng Recall@K, MRR, NDCG trên test set.
  • Biết các pitfall hay gặp (metric mismatch, K quá thấp / cao, quên normalize).

Bài này tập trung vào retrieval logic, không phải vào API riêng của ChromaDB / Qdrant — kiến thức ở đây áp được cho mọi vector DB.

2

Similarity search và top-K

Similarity search = với một vector truy vấn \( q \) trong không gian \( \mathbb{R}^d \), tìm các vector trong collection có "khoảng cách" gần \( q \) nhất theo một distance metric đã chọn.

Top-K retrieval = trong các vector gần nhất đó, trả về \( K \) cái đầu kèm score và chunk text gốc. \( K \) thường nằm trong khoảng 3-50 tùy use case.

Bài toán hình thức:

\[ \text{top}_K(q) = \mathop{\arg\text{top-}K}_{v \in V} \, \text{sim}(q, v) \]

Trong đó \( V \) là tập vector trong collection và \( \text{sim} \) là hàm tương đồng. Phần thú vị nằm ở hai chỗ: (a) chọn metric nào cho \( \text{sim} \) (bước 3-6), (b) duyệt qua \( V \) cách nào cho nhanh khi \( |V| \) lớn (bước 7-10).

3

Cosine similarity

Đo góc giữa hai vector, không quan tâm độ dài:

\[ \cos(\theta) = \frac{\mathbf{u} \cdot \mathbf{v}}{\|\mathbf{u}\| \, \|\mathbf{v}\|} = \frac{\sum_i u_i v_i}{\sqrt{\sum_i u_i^2} \, \sqrt{\sum_i v_i^2}} \]

  • Range \( [-1, 1] \). Giá trị càng cao càng "similar"; \( 1 \) là cùng hướng, \( 0 \) là vuông góc, \( -1 \) là ngược hướng.
  • Invariant với magnitude: scale một vector lên 10 lần không đổi cosine. Tốt cho text embedding vì độ dài vector không mang ý nghĩa.
  • Là default metric cho hầu hết embedding model text (sentence-transformers, BGE, E5, OpenAI).

Trong nhiều vector DB, "cosine distance" được lưu là \( 1 - \cos(\theta) \in [0, 2] \) (thấp = similar). ChromaDB trả về dạng này — khi đọc distances nhớ kiểm tra convention.

4

Euclidean distance

Khoảng cách "đường chim bay" theo norm L2:

\[ d(\mathbf{u}, \mathbf{v}) = \sqrt{\sum_i (u_i - v_i)^2} \]

  • Range \( [0, \infty) \). Càng nhỏ càng similar.
  • Sensitive với magnitude: hai vector cùng hướng nhưng khác độ dài có Euclidean lớn.
  • Phù hợp khi magnitude mang ý nghĩa: ví dụ image embedding chưa normalize, feature numeric raw.
  • Ít dùng làm metric chính cho text embedding (đa số embedding model train với cosine objective).

Lưu ý: nếu vector đã được L2-normalize (\( \|\mathbf{u}\| = 1 \)) thì Euclidean và cosine cho cùng thứ tự ranking — chứng minh từ đẳng thức \( d^2 = 2 - 2 \cos\theta \). Khi đó dùng metric nào cũng được, chỉ ảnh hưởng convention score.

5

Dot product

Tích vô hướng đơn thuần, không chia norm:

\[ \mathbf{u} \cdot \mathbf{v} = \sum_i u_i v_i \]

  • Range \( (-\infty, \infty) \). Càng lớn càng similar.
  • Khi vector đã pre-normalize (\( \|\mathbf{u}\| = \|\mathbf{v}\| = 1 \)): dot product = cosine similarity.
  • Nhanh hơn cosine (bỏ qua phép chia norm). Khác biệt nhỏ trên hardware hiện đại nhưng đáng kể khi search hàng triệu vector.
  • Một số embedding model (ColBERT, một số dual-encoder) train với dot product objective không normalize — khi đó dùng dot product mới đúng.

Quy tắc thực dụng: nếu pipeline đảm bảo vector đã L2-normalize lúc index và lúc query, có thể chọn dot product để tận dụng tốc độ; nếu không chắc, cứ chọn cosine cho an toàn.

6

Khi nào dùng metric nào

Tình huống                              Metric nên chọn
─────────────────────────────────────────────────────────────────
Text embedding (BGE, E5, OpenAI, ...)    Cosine (default)
Vector đã L2-normalize, cần tốc độ       Dot product
Image / feature numeric raw              Euclidean
ColBERT / late-interaction               Dot product (sum-max)
Multilingual, đa domain                  Cosine
Hỗn hợp embedding khác nguồn             Cosine (an toàn)

Quan trọng: metric khi query phải khớp với metric khi build index. Build HNSW với "cosine" rồi query với "L2" → kết quả sai. Mọi vector DB đều bắt khai báo metric lúc tạo collection — đừng đổi sau.

Quy tắc đơn giản: với text RAG, mặc định cosine, không suy nghĩ nhiều. Đổi metric không phải chỗ tối ưu sớm; chunking và embedding model có impact lớn hơn nhiều.

7

Exact KNN vs ANN

Có hai cách tìm top-K:

  • Exact KNN (brute-force) — tính distance từ query tới mọi vector trong collection, rồi sắp xếp lấy top-K. Phức tạp \( O(n \cdot d) \) với \( n \) vector chiều \( d \). Trả về đúng top-K thật.
  • Approximate Nearest Neighbor (ANN) — dùng cấu trúc dữ liệu (graph, tree, hash, inverted file) để chỉ duyệt một phần collection. Phức tạp ~\( O(\log n) \) hoặc \( O(\sqrt{n}) \) tùy thuật toán. Có thể bỏ lỡ một vài top-K thật → đo bằng recall.
Trục               Exact KNN              ANN
─────────────────────────────────────────────────────────
Accuracy           100% (đúng top-K)      90-99% recall
Speed              O(n)                   O(log n)
Memory             O(n*d)                 Lớn hơn (index)
Build time         0                      Vài phút - vài giờ
Khi nào dùng       n < 10K, eval ref      n > 100K, prod

Trade-off: speed đổi lấy chút accuracy. Trong RAG, mất 1-2% recall thường không ảnh hưởng đáng kể đến chất lượng câu trả lời cuối — đặc biệt khi đã có reranker (Bài 40) sửa lại top sau cùng.

Quy tắc: dataset < 10K vector dùng brute-force vẫn ổn (1-2ms cho \( d = 384 \)); > 100K thì gần như bắt buộc dùng ANN.

8

HNSW

HNSW (Hierarchical Navigable Small World) — Malkov & Yashunin 2016 (arXiv 1603.09320). Là ANN graph-based phổ biến nhất 2023-2026.

Ý tưởng: build nhiều tầng graph; tầng trên cùng có ít node, kết nối "xa", tìm thô; tầng dưới đông node, kết nối "gần", refine. Search bắt đầu ở tầng cao, đi xuống dần đến tầng 0.

Tầng L (~log n node)  ●───────────●───────────●     (long-range)
                       │           │           │
Tầng 1                 ●───●───●───●───●───●───●     (medium-range)
                       │   │   │   │   │   │   │
Tầng 0 (tất cả node)   ●─●─●─●─●─●─●─●─●─●─●─●─●     (short-range)

Parameter chính:

  • M — số neighbor tối đa mỗi node giữ (thường 16-64). Lớn = chính xác hơn, tốn memory hơn.
  • ef_construction — kích thước candidate list khi build (thường 100-400). Lớn = index chậm hơn nhưng chất lượng tốt hơn.
  • ef_search (hoặc ef) — kích thước candidate list khi query (thường 50-200). Tăng lúc query để đổi tốc độ lấy recall.

Là default index trong ChromaDB, Qdrant, Pinecone, Weaviate, Milvus, pgvector (qua extension). Cân bằng speed + accuracy tốt cho hầu hết case RAG.

9

IVF

IVF (Inverted File) — Faiss (Facebook AI Research). Ý tưởng: cluster toàn bộ vector thành \( k \) cluster bằng K-Means, mỗi cluster có một centroid. Lúc search, chỉ tính distance trong vài cluster gần query nhất.

Build phase:
  1. Train K-Means trên vector → k centroid
  2. Gán mỗi vector vào cluster của centroid gần nhất

Query phase:
  1. Tìm nprobe centroid gần query nhất (thường 1-32)
  2. Brute-force trong các cluster đó
  3. Lấy top-K

Parameter chính:

  • nlist — số cluster, thường \( \approx \sqrt{n} \).
  • nprobe — số cluster duyệt khi query. Lớn = recall cao hơn, chậm hơn.

So với HNSW: IVF cần train trước (K-Means trên sample), build nhanh hơn nhưng search chất lượng thường thua một bậc. Tốt cho corpus rất lớn (10M+) khi memory cho HNSW thành vấn đề. Faiss còn có biến thể IVF-PQ kết hợp với product quantization (bước 10).

10

ScaNN và Product Quantization

ScaNN (Scalable Nearest Neighbors) — Google Research 2020 (Guo et al., ICML). Kết hợp anisotropic quantization với một tree phân vùng và residual scoring. Là backbone search trong nhiều dịch vụ Google. Production-grade, có Python binding, throughput cao hơn Faiss trên benchmark ANN-Benchmarks khi tune kỹ.

Product Quantization (PQ) — Jégou et al. 2011. Chia vector chiều \( d \) thành \( m \) sub-vector chiều \( d/m \). Mỗi sub-vector được quantize về một codebook 256-entry → mỗi sub-vector chỉ cần 1 byte. Vector chiều 768 với \( m = 96 \) đi từ \( 768 \times 4 = 3072 \) byte (float32) xuống còn 96 byte → giảm 32 lần.

Trục              IVF       HNSW      ScaNN     IVF-PQ
─────────────────────────────────────────────────────────
Speed             Medium    Fast      Fast      Medium
Recall            Medium    High      High      Medium-Low
Memory            Low       High      Medium    Very low
Build time        Fast      Slow      Medium    Fast
Update friendly   Tệ        Trung     Trung     Tệ

Trade-off chính của PQ: storage vs accuracy. Mất ~5-10% recall đổi lấy memory giảm 10-30 lần. Dùng khi corpus quá lớn không fit RAM.

Thực tế trong RAG cấp prototype/SME: HNSW là đủ. PQ và IVF dùng ở scale lớn (Pinecone, Vespa, ScaNN service Google).

11

Chọn K

Số chunk trả về cho LLM. Trade-off cơ bản: K lớn = nhiều context hơn nhưng cũng nhiều noise hơn, tốn token, có thể trigger lost-in-the-middle.

K        Use case                                  Ghi chú
─────────────────────────────────────────────────────────────────────
3-5      Câu hỏi đơn, FAQ, precise lookup           Ít noise, prompt ngắn
10-20    Câu hỏi cần tổng hợp, multi-aspect         Cân bằng phổ thông
50+      Pre-rerank: retrieve nhiều, rerank chọn    Cặp với Bài 40

Quy tắc thực dụng:

  • Bắt đầu với \( K = 5 \) cho prototype.
  • Nếu thấy retrieve hay miss → tăng K hoặc xem lại chunk size / embedding.
  • Nếu thấy LLM "lạc" hoặc cite sai → giảm K, hoặc thêm reranker.
  • Khi dùng reranker (Bài 40): retrieve \( K_1 = 30-50 \) rồi rerank xuống \( K_2 = 3-5 \) — pattern phổ biến nhất.

Quan trọng: tune K trên test set có golden answer, không tune theo cảm tính. Cách đo ở bước 23.

12

Distance threshold filter

Sau top-K, lọc thêm theo ngưỡng similarity / distance để loại chunk thực ra không liên quan. Vector DB luôn trả K chunk kể cả khi không có chunk nào "đủ gần" — nếu không filter, LLM nhận context rác và có thể bịa.

THRESHOLD = 0.5  # cosine similarity tối thiểu

filtered = [
    (doc, score)
    for doc, score in zip(docs, scores)
    if score >= THRESHOLD
]

if not filtered:
    return "Không tìm thấy tài liệu liên quan."

Lưu ý convention: ChromaDB trả distance (thấp = similar) chứ không phải similarity. Phải convert hoặc dùng \( \text{sim} = 1 - \text{distance} \) cho cosine.

Chọn threshold: chạy trên test set, vẽ histogram score của (a) cặp (query, doc liên quan) và (b) cặp ngẫu nhiên. Threshold đặt giữa hai phân phối. Threshold quá cao → no result; quá thấp → noise. 0.5-0.7 cosine là khoảng phổ biến cho text embedding, nhưng phụ thuộc mạnh vào embedding model — phải đo cho từng pipeline.

13

Metadata filtering

Hầu hết vector DB cho lưu metadata kèm vector (category, date, author, language, ...). Search có thể kết hợp vector similarity với điều kiện trên metadata:

  • Pre-filter — vector DB lọc metadata trước, chỉ tính similarity trong tập đã lọc. Đúng về ngữ nghĩa, có thể nhanh hơn nếu DB hỗ trợ index trên metadata.
  • Post-filter — vector DB tìm top-K trước, lọc metadata sau. Dễ làm nhưng nếu filter chặt có thể còn ít hơn K kết quả.
  • Hybrid query — kết hợp cả vector score và metadata score (Weaviate, Qdrant).
results = collection.query(
    query_texts=["..."],
    n_results=5,
    where={"category": "tech", "year": {"$gte": 2025}},  # metadata filter
)

Pre-filter mạnh ở các case như "chỉ tìm trong tài liệu năm 2026", "chỉ trong loại 'pricing'", "chỉ trong tenant X" — giảm không gian search và đảm bảo isolation.

Pitfall: filter quá chặt + threshold cao → no result. Cần fallback (relax filter, mở rộng K, báo cho user).

14

Hybrid search — dense + sparse

Vector search (dense) hiểu nghĩa nhưng có thể trượt keyword cụ thể (mã sản phẩm, tên function, viết tắt). BM25 (sparse) match keyword chính xác nhưng không hiểu paraphrase. Kết hợp cả hai gọi là hybrid search.

                ┌─→ Dense (vector)  top-K_d ─┐
User query  ────┤                                ├─→ Fusion ─→ Top-K
                └─→ Sparse (BM25)   top-K_s ─┘

Hai cách gộp phổ biến:

  • Weighted sum: \( \text{score} = \alpha \cdot \text{score}_{\text{dense}} + (1 - \alpha) \cdot \text{score}_{\text{sparse}} \). Cần normalize hai score về cùng scale (min-max hoặc z-score).
  • Reciprocal Rank Fusion (RRF) — chỉ dùng rank, không cần normalize score. Bước 16 deep-dive.

Quy tắc: hybrid gần như luôn không tệ hơn dense thuần, thường cải thiện 5-15% recall trên corpus có nhiều term kỹ thuật. Vector DB hỗ trợ native: Qdrant, Weaviate, Elasticsearch. Manual cũng dễ với rank_bm25 + vector retriever (xem bước 21).

15

BM25 — sparse retrieval

BM25 (Best Match 25) — Robertson & Spärck Jones 1994, biến thể nâng cấp của TF-IDF, là baseline lexical mạnh nhất cho retrieval. Ranking dựa trên:

  • Term frequency (TF) — từ xuất hiện càng nhiều trong doc, score càng cao (có saturation).
  • Inverse document frequency (IDF) — từ hiếm trong toàn corpus có trọng số cao hơn.
  • Document length normalization — bù cho doc dài.

Trong Python, library rank_bm25 đủ dùng cho dataset cỡ vừa. ElasticSearch / OpenSearch là lựa chọn production. BM25 mạnh đặc biệt cho:

  • Mã sản phẩm, tên function, viết tắt, version number.
  • Câu hỏi có keyword cụ thể.
  • Ngôn ngữ ít resource embedding model.

BM25 vẫn là một baseline rất khó vượt: trên nhiều benchmark domain hẹp, dense embedding không tune kỹ chỉ ngang hoặc thua BM25.

16

Reciprocal Rank Fusion (RRF)

RRF (Cormack et al. 2009) là cách gộp nhiều ranking list mà không cần normalize score, chỉ dùng thứ hạng:

\[ \text{RRF}(d) = \sum_{i=1}^{L} \frac{1}{k + r_i(d)} \]

Trong đó:

  • \( L \) — số ranking list cần gộp (ví dụ 2: dense và sparse).
  • \( r_i(d) \) — rank của doc \( d \) trong list \( i \), bắt đầu từ 1; nếu \( d \) không có trong list \( i \) thì coi \( r_i(d) = \infty \) (số hạng = 0).
  • \( k \) — hằng số làm "êm" rank đầu, mặc định \( k = 60 \) trong paper gốc.

Vì sao tốt: không cần biết score của từng retriever (cosine vs BM25 vs cross-encoder không cùng scale), chỉ cần thứ hạng. Robust, ít hyperparameter, dễ debug. LangChain EnsembleRetriever, LlamaIndex QueryFusionRetriever đều dùng RRF mặc định.

Cùng pattern dùng được khi có nhiều câu query paraphrase (Multi-Query / RAG-Fusion ở Bài 35) hoặc nhiều embedding model song song.

17

MMR — diversify top-K

Vấn đề: top-K theo similarity thuần có thể trùng lặp — 5 chunk gần giống nhau cùng nói một ý, không bổ sung context mới. LLM nhận context dư thừa, miss khía cạnh khác.

MMR (Maximal Marginal Relevance) — Carbonell & Goldstein 1998. Sau khi đã có candidate top-N, chọn lần lượt K chunk vừa liên quan vừa khác các chunk đã chọn:

\[ \text{MMR} = \arg\max_{d_i \in C \setminus S} \big[ \lambda \cdot \text{sim}(q, d_i) - (1 - \lambda) \cdot \max_{d_j \in S} \text{sim}(d_i, d_j) \big] \]

  • \( C \) — candidate set; \( S \) — đã chọn.
  • \( \lambda \in [0, 1] \): \( \lambda = 1 \) là pure relevance (giống top-K thường); \( \lambda = 0 \) là pure diversity. Mặc định 0.5-0.7.

Hữu ích khi: knowledge base có nhiều bản trùng lặp, câu hỏi cần nhiều aspect, hoặc dùng cho "explore" thay vì "exact answer". LangChain hỗ trợ qua search_type="mmr" trong retriever.

Trade-off: thêm chút compute (tính similarity cặp giữa các candidate), nhưng cải thiện diversity đáng kể, đặc biệt sau hybrid search.

18

Multi-vector search

Một document có thể được biểu diễn bằng nhiều vector thay vì một:

  • Chunk-level — mỗi chunk có 1 vector; 1 doc thường có 5-50 chunk. Đây là pattern RAG mặc định.
  • Multi-representation — mỗi doc có (a) vector từ chunk gốc, (b) vector từ summary do LLM viết, (c) vector từ hypothetical question. Search trong cả 3.
  • Late interaction (ColBERT, Khattab & Zaharia 2020) — mỗi token có một vector; score giữa query và doc là tổng max-similarity từng token query với token doc. Chính xác hơn nhưng tốn storage hơn.

Aggregation per doc khi có nhiều chunk: thường lấy max score giữa các chunk thuộc cùng doc, hoặc mean top-3. Sau đó dedup ở mức doc nếu cần.

Pattern phổ biến nhất 2024-2026 vẫn là chunk-level + reranker. Multi-representation và ColBERT thuộc advanced, đáng thử khi đã đo và cần thêm vài phần trăm recall.

19

Code ChromaDB top-K

Search top-K cosine với metadata filter (giả sử collection đã có ở Bài 37):

import chromadb

client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_collection("docs")

results = collection.query(
    query_texts=["Cosine similarity là gì?"],
    n_results=5,
    where={"category": "tech"},  # metadata filter
)

docs = results["documents"][0]
distances = results["distances"][0]  # cosine distance: thấp = similar
metadatas = results["metadatas"][0]

THRESHOLD_DIST = 0.5  # cosine distance < 0.5 (tức similarity > 0.5)
for doc, dist, meta in zip(docs, distances, metadatas):
    if dist > THRESHOLD_DIST:
        continue
    sim = 1 - dist
    print(f"sim={sim:.3f} | {meta.get('source', '?')}: {doc[:80]}")

Ghi chú:

  • Khi tạo collection nhớ chỉ định metadata={"hnsw:space": "cosine"} để dùng cosine; mặc định là L2 ở một số phiên bản.
  • Có thể truyền sẵn query_embeddings thay vì query_texts để tránh embed lại.
  • Threshold trên là minh họa — tune theo dataset.
20

Code NumPy manual cosine + top-K

Brute-force để hiểu rõ và để verify khi debug:

import numpy as np

def cosine_sim(a: np.ndarray, b: np.ndarray) -> float:
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9))

def top_k(query_vec: np.ndarray, doc_vecs: np.ndarray, k: int = 5):
    # doc_vecs shape: (n, d)
    sims = doc_vecs @ query_vec / (
        np.linalg.norm(doc_vecs, axis=1) * np.linalg.norm(query_vec) + 1e-9
    )
    top_idx = np.argsort(sims)[-k:][::-1]
    return top_idx, sims[top_idx]

# Ví dụ
rng = np.random.default_rng(0)
docs = rng.standard_normal((1000, 384)).astype(np.float32)
q = rng.standard_normal(384).astype(np.float32)

idx, scores = top_k(q, docs, k=5)
for i, s in zip(idx, scores):
    print(f"doc {i}: cosine={s:.3f}")

Với 1000 vector chiều 384, phép này chạy < 5ms trên CPU thường. Là baseline tham chiếu để so sánh recall của HNSW.

21

Code hybrid BM25 + vector + RRF

pip install rank_bm25 sentence-transformers
import numpy as np
from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer

docs = [...]  # list[str] gồm N chunk

# 1) Build sparse index
tokenized = [d.lower().split() for d in docs]
bm25 = BM25Okapi(tokenized)

# 2) Build dense index
embedder = SentenceTransformer("all-MiniLM-L6-v2")
doc_vecs = embedder.encode(docs, normalize_embeddings=True)

def search_dense(query: str, k: int):
    q_vec = embedder.encode([query], normalize_embeddings=True)[0]
    sims = doc_vecs @ q_vec  # dot = cosine vì đã normalize
    return np.argsort(sims)[-k:][::-1].tolist()

def search_sparse(query: str, k: int):
    scores = bm25.get_scores(query.lower().split())
    return np.argsort(scores)[-k:][::-1].tolist()

def rrf(rankings: list[list[int]], k_rrf: int = 60, top_k: int = 5):
    scores: dict[int, 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 / (k_rrf + rank)
    return sorted(scores, key=scores.get, reverse=True)[:top_k]

q = "cosine similarity dùng để làm gì"
dense_top = search_dense(q, k=20)
sparse_top = search_sparse(q, k=20)
final = rrf([dense_top, sparse_top], top_k=5)

for i in final:
    print(docs[i][:120])

Khoảng 30 dòng cho hybrid + RRF chạy được. Production sẽ thay bm25.get_scores (O(n)) bằng Elasticsearch / OpenSearch khi corpus lớn.

22

Code MMR

import numpy as np

def mmr(query_vec, doc_vecs, doc_ids, k=5, lambda_=0.7):
    """Greedy MMR. Giả định vector đã normalize -> dot = cosine."""
    sims_to_q = doc_vecs @ query_vec
    selected: list[int] = []
    candidates = list(range(len(doc_ids)))

    while len(selected) < k and candidates:
        if not selected:
            best = max(candidates, key=lambda i: sims_to_q[i])
        else:
            sel_vecs = doc_vecs[selected]
            best = max(
                candidates,
                key=lambda i: (
                    lambda_ * sims_to_q[i]
                    - (1 - lambda_) * float((doc_vecs[i] @ sel_vecs.T).max())
                ),
            )
        selected.append(best)
        candidates.remove(best)

    return [doc_ids[i] for i in selected]

Đầu vào nên là candidate top-N (N = 20-50) đã filter trước, không phải toàn corpus. Tính \( O(N \cdot K) \), nhẹ.

23

Evaluation — Recall@K, MRR, NDCG

Ba metric thông dụng cho retrieval, cần một test set \( \{(q, R_q)\} \) trong đó \( R_q \) là tập doc thực sự liên quan với query \( q \).

Recall@K — trong top-K retrieve, có bao nhiêu phần trăm của \( R_q \) được tìm thấy:

\[ \text{Recall@}K = \frac{|\text{retrieved}_K \cap R_q|}{|R_q|} \]

MRR (Mean Reciprocal Rank) — trung bình nghịch đảo của rank doc liên quan đầu tiên:

\[ \text{MRR} = \frac{1}{|Q|} \sum_{q \in Q} \frac{1}{\text{rank}_q} \]

\( \text{rank}_q \) là vị trí (1-based) của doc liên quan đầu tiên cho query \( q \). Nếu không thấy trong top-K, coi \( \frac{1}{\text{rank}_q} = 0 \).

NDCG (Normalized Discounted Cumulative Gain) — discount theo position, xét graded relevance (chunk "rất liên quan" tính nặng hơn "có chút liên quan"):

\[ \text{DCG@}K = \sum_{i=1}^{K} \frac{2^{\text{rel}_i} - 1}{\log_2(i + 1)}, \quad \text{NDCG@}K = \frac{\text{DCG@}K}{\text{IDCG@}K} \]

NDCG là metric "đầy đủ" nhất nhưng cần graded relevance label, công sức build test set lớn hơn. Cho prototype, Recall@5 và MRR đủ dùng.

Build test set: chọn 30-100 câu hỏi tiêu biểu, với mỗi câu xác định 1-3 chunk thực sự chứa câu trả lời. Đo trước, đo sau, mỗi lần thay đổi chunk size / embedding / K / metric.

24

Optimize search

  • Batched search — gửi N query cùng lúc thay vì lần lượt. Vector DB tận dụng được vectorization, throughput tăng 5-20 lần.
  • Pre-filter metadata — giảm không gian search trước khi tính similarity.
  • Lower K + rerank — retrieve top 30-50 với HNSW giá rẻ, rerank xuống top 3-5 bằng cross-encoder (Bài 40). Vừa nhanh vừa accurate.
  • Quantization — PQ / scalar quantization giảm memory, tốc độ tốt hơn; chấp nhận giảm vài % recall.
  • Cache query embedding — query phổ thông (FAQ) sẽ lặp lại; cache vector + cache cả response.
  • Tune ef_search trong HNSW — tăng ở route quan trọng, giảm ở route phụ.
  • Async / parallel dense + sparse khi làm hybrid — chạy song song chứ không tuần tự.

Quy tắc: tối ưu sau khi đã có metric đo (bước 23). Đừng tối ưu mò.

25

Pitfall hay gặp

  • Metric mismatch index vs query — build collection với cosine rồi query với L2 (hoặc ngược lại). Kết quả ranking sai im lặng.
  • Quên normalize khi dùng dot product — score không phản ánh cosine, lệch hẳn ranking.
  • K quá thấp — miss chunk liên quan, LLM bịa.
  • K quá cao — nhiều noise, tốn token, có thể trigger lost-in-the-middle.
  • Threshold quá nghiêm — no result trên câu hỏi vẫn có thể trả lời được.
  • Embedding model lúc index khác lúc query — vector ở "không gian khác", retrieve random.
  • Không filter dedup chunk — top-K gồm 5 bản trùng lặp, context dư thừa.
  • Confuse distance vs similarity — ChromaDB trả distance (thấp = similar), nhiều người tưởng score lớn = tốt.
  • Không có test set — mọi thay đổi pipeline là cảm tính, không biết tốt lên hay xấu đi.
  • Tin hoàn toàn vào ANN — HNSW có thể miss vài % top-K thật; nếu use case quá nhạy, dùng brute-force hoặc đo recall thường xuyên.
26

Bài tập

  1. Build collection ChromaDB với 50 đoạn văn của bạn (note, blog, tài liệu kỹ thuật). Tạo 5 câu hỏi, với mỗi câu xác định trước 1-2 chunk "đúng". Đây là test set mini.
  2. Trên cùng 50 chunk, build hai collection: một với hnsw:space="cosine", một với "l2". So sánh top-5 cho 5 câu hỏi; tính Recall@5 cho từng metric.
  3. Implement hybrid search BM25 + dense + RRF theo code bước 21 trên 50 chunk. So sánh top-5 với dense thuần và với BM25 thuần. Tính Recall@5 cho cả ba.
  4. Áp dụng MMR (bước 22) lên candidate top-20 của dense; so sánh với top-5 trực tiếp về (a) Recall@5, (b) số chunk trùng lặp về nội dung.
  5. Quét K từ \( \{3, 5, 10, 20, 50\} \), với mỗi K đo Recall@K và MRR trên 5 câu hỏi. Vẽ đồ thị; chọn K tối ưu.
  6. (Tùy chọn) Đọc abstract của paper HNSW (arXiv 1603.09320) và ScaNN (arXiv 1908.10396). Tóm tắt 3 ý chính sự khác nhau về cấu trúc index.