Published on

Day 37: Reranking Cho Production RAG

Authors

1. Reranking giải quyết vấn đề gì?

Trong Day 36, pipeline Hybrid Search đã lấy candidate bằng BM25, dense retrieval và Reciprocal Rank Fusion. Bước đó tối ưu cho recall: cố gắng không bỏ sót chunk có khả năng liên quan. Nhưng top result của retriever không luôn là context tốt nhất cho LLM.

Các lỗi thường gặp khi chỉ dùng retrieval top-k:

  • Dense retrieval hiểu ý nghĩa tổng quát nhưng có thể bỏ qua phủ định, điều kiện, version hoặc tên riêng.
  • BM25 bắt đúng keyword nhưng có thể đưa lên cao đoạn chỉ nhắc từ khóa, không trả lời câu hỏi.
  • RRF merge nhiều nguồn tốt cho recall nhưng thứ tự sau merge vẫn còn nhiễu.
  • Chunk gần giống nhau làm top-k bị trùng, khiến context thiếu đa dạng.
  • Query tiếng Việt trộn English, acronym, mã lỗi hoặc tên sản phẩm làm embedding khó xếp đúng.

Reranker nhận từng cặp:

(query, candidate_chunk) -> relevance_score

Sau đó hệ thống sắp xếp lại candidate và chỉ lấy top nhỏ hơn để đưa vào prompt.

Mental model:

Retriever: tìm rộng để không bỏ sót.
Reranker: chấm đắt hơn nhưng chính xác hơn trên tập candidate nhỏ.
Context builder: lấy vài chunk tốt nhất, giữ citation và giới hạn token.

Điểm quan trọng: reranker không cứu được chunk không nằm trong candidate pool. Nếu retrieve top 50 không có chunk đúng, rerank top 50 cũng không thể tạo ra chunk đúng. Vì vậy production pattern thường là retrieve rộng trước, sau đó rerank hẹp.

2. Bi-encoder vs cross-encoder

Bi-encoder encode query và document riêng biệt:

query -> embedding_q
doc   -> embedding_d
score = similarity(embedding_q, embedding_d)

Ưu điểm là document embedding có thể tính offline và lưu trong Vector DB. Query runtime chỉ cần embed query rồi search nhanh. Nhược điểm là query và document không tương tác token-by-token khi chấm điểm, nên ranking có thể sai ở các câu hỏi cần hiểu chi tiết.

Cross-encoder encode query và document cùng lúc:

[query, doc] -> transformer -> relevance_score

Ưu điểm là model nhìn thấy tương tác giữa từng token của query và chunk, nên thường xếp hạng chính xác hơn. Nhược điểm là không thể precompute score cho mọi query-doc pair. Nếu có 100 candidate, cross-encoder phải chạy 100 cặp ở runtime.

Cách làmDùng choƯu điểmNhược điểmProduction note
Bi-encoderFirst-stage retrievalNhanh, index được offline, scale tốtRanking chưa đủ tinhDefault cho dense retrieval
Cross-encoderSecond-stage rerankingTop-k precision tốtLatency/cost tăng theo số candidateRerank top 20-100, không rerank toàn corpus
Late-interactionSearch/rerank chất lượng caoGiữ token-level matching tốt hơn bi-encoderServing/index phức tạpPhù hợp team search mạnh
LLM rerankQuery đặc biệt phức tạpLinh hoạt, có thể giải thíchĐắt, chậm, khó deterministicDùng có chọn lọc hoặc offline analysis

Rule thực dụng: dùng bi-encoder để retrieve, dùng cross-encoder để rerank.

3. Reranker là gì?

Reranker là một model hoặc service nhận query và danh sách candidate, trả về relevance score hoặc danh sách đã sắp xếp.

Input tốt cho reranker không chỉ là chunk.text. Nó nên chứa đủ ngữ cảnh ngắn:

  • title của tài liệu.
  • section_path hoặc heading.
  • Nội dung chunk đã cắt vừa token budget.
  • source_type, document_version, effective_date nếu domain phụ thuộc version.
  • page_start, page_end, chunk_id để giữ citation.

Ví dụ format text đưa vào reranker:

Title: Chính sách nghỉ phép 2026
Section: HR > Leave Policy > Annual Leave
Version: 2026-01
Text: Nhân viên full-time có 12 ngày nghỉ phép năm...

Không nên đưa cả document dài vào reranker. Tài liệu dài sẽ bị truncate, tăng latency và có thể làm mất đúng phần chứa đáp án. Chunking tốt từ Day 35 vẫn rất quan trọng.

4. BGE reranker

BGE reranker là nhóm reranker open-source từ BAAI, thường dùng như cross-encoder cho search/RAG. Với dữ liệu tiếng Việt hoặc multilingual, một lựa chọn thực tế là BAAI/bge-reranker-v2-m3 vì model này hỗ trợ multilingual và có thể chạy self-host.

Ưu điểm:

  • Không gửi dữ liệu ra managed API nếu self-host.
  • Có thể batch inference để tối ưu throughput.
  • Có thể fine-tune trên query/document domain riêng khi có dữ liệu.
  • Phù hợp để học và để xây baseline nội bộ.

Nhược điểm:

  • Cần CPU/GPU serving, autoscale, monitoring và model lifecycle.
  • Latency tăng theo candidate_count * chunk_length.
  • Cần kiểm tra license, hardware và khả năng vận hành trước production.

Ví dụ dùng sentence-transformers:

from sentence_transformers import CrossEncoder

model = CrossEncoder("BAAI/bge-reranker-v2-m3", max_length=512)

query = "Nhân viên full-time được nghỉ phép bao nhiêu ngày trong năm 2026?"
passages = [
    "Title: Chính sách nghỉ phép 2026\nText: Nhân viên full-time có 12 ngày nghỉ phép năm.",
    "Title: Quy định bảo mật\nText: Nhân viên phải đổi mật khẩu mỗi 90 ngày.",
]

scores = model.predict([(query, passage) for passage in passages])

Trong production, đoạn trên nên được bọc trong service có batching, timeout, circuit breaker, metric và model version.

5. Cohere Rerank concept

Cohere Rerank là managed rerank API. Thay vì tự host model, hệ thống gửi query và documents lên API, nhận về relevance score cùng thứ tự kết quả.

Ưu điểm:

  • Ship nhanh, ít vận hành model serving.
  • API đã có batching, model hosting, scaling.
  • Dễ dùng để tạo baseline chất lượng trước khi quyết định self-host.

Nhược điểm:

  • Có network latency và rate limit.
  • Cost tăng theo số request, số document và độ dài document.
  • Cần đánh giá data privacy, PII, data residency và hợp đồng xử lý dữ liệu.
  • Vendor/model version thay đổi có thể làm score distribution đổi.

Ví dụ concept với Python SDK:

import os

import cohere

co = cohere.Client(token=os.environ["COHERE_API_KEY"])

response = co.v2.rerank(
    model="rerank-v4.0-pro",
    query="Nhân viên full-time được nghỉ phép bao nhiêu ngày?",
    documents=[
        "Title: Chính sách nghỉ phép\nText: Nhân viên full-time có 12 ngày nghỉ phép năm.",
        "Title: Chính sách bảo mật\nText: Không chia sẻ mật khẩu cho người khác.",
    ],
    top_n=2,
)

for item in response.results:
    print(item.index, item.relevance_score)

Trong hệ thống thật, không hard-code model. Hãy đưa model name, top_n, timeout và max_tokens_per_doc vào config.

6. Two-stage retrieval

Pipeline production hợp lý sau Day 36:

user query
  -> normalize query
  -> BM25 top 50 + vector top 50
  -> merge bằng RRF
  -> filter tenant/ACL/deleted/index_version
  -> dedupe theo document_id/chunk_id/text_hash
  -> truncate text theo max tokens per doc
  -> rerank top 50 hoặc top 100
  -> lấy top 5-10
  -> context builder giữ citation
  -> LLM answer

Thứ tự ACL rất quan trọng. Filter quyền phải chạy trước rerank và trước khi gửi sang managed API. Nếu candidate không đúng quyền được gửi vào prompt hoặc ra API ngoài, dữ liệu đã bị leak.

Candidate sizing:

Cấu hìnhKhi phù hợpRủi ro
Retrieve top 20 -> rerank top 5Corpus nhỏ, SLA rất chặtDễ bỏ sót chunk đúng
Retrieve top 50 -> rerank top 5/8Default tốt cho nhiều RAG nội bộVẫn cần đo Recall@50
Retrieve top 100 -> rerank top 10Cần recall cao, query đa dạng, corpus nhiều nhiễuLatency/cost tăng
Retrieve top 200+ -> rerank top 10Chỉ dùng khi đã chứng minh cầnCó thể quá đắt, p95 xấu

Default thực dụng cho bài học: BM25 top 50, vector top 50, RRF merge, dedupe còn khoảng 50-100 candidate, rerank lấy top 5-10.

7. Latency và cost trade-off

Reranker nằm trước bước LLM generation, nên nó ảnh hưởng trực tiếp tới time-to-first-token.

Các yếu tố làm latency tăng:

  • Số candidate rerank.
  • Độ dài chunk sau khi format.
  • Model size.
  • CPU vs GPU.
  • Batch size và queue time.
  • Managed API network latency, rate limit và retry.

Trade-off quan trọng:

Quyết địnhQualityLatencyCostGhi chú
Không rerankThấp đến vừaTốtThấpChỉ ổn nếu eval chứng minh top-k đã đủ
Rerank top 20VừaTốtVừaPhù hợp SLA chặt
Rerank top 50TốtVừaVừaDefault nên thử
Rerank top 100Tốt hơn nếu retrieval recall caoChậm hơnCao hơnCần benchmark p95
Self-host BGETốt nếu vận hành ổnPhụ thuộc hardwarePredictable hơn ở scaleCần GPU/CPU serving
Cohere RerankTốt, ship nhanhCó network latencyPay-per-useCần review privacy

Kỹ thuật giảm latency:

  • Dedupe trước rerank.
  • Cắt text theo heading/sentence boundary và giới hạn max_chars hoặc max_tokens_per_doc.
  • Batch candidate pairs.
  • Cache với query normalize nếu không chứa PII và dữ liệu không quá volatile.
  • Timeout reranker và fallback về RRF rank.
  • Chỉ rerank khi query cần độ chính xác cao, ví dụ policy/legal/support.
  • Dùng model nhỏ hơn cho traffic thường, model mạnh hơn cho query có risk cao.

8. Evaluation: Recall@k và MRR

Reranking nên được đo bằng query set có ground truth, không đo bằng cảm giác.

Một eval item tối thiểu:

{
  "query_id": "q001",
  "query": "Nhân viên full-time có bao nhiêu ngày nghỉ phép năm 2026?",
  "relevant_chunk_ids": ["hr_leave_2026:chunk_003"]
}

Các metric:

MetricDùng để đoCách đọc
Recall@50 before rerankCandidate pool có chứa chunk đúng khôngThấp thì sửa retrieval/chunking trước
Recall@5 after rerankContext cuối có chứa chunk đúng khôngCao hơn nghĩa là rerank giúp top context
MRR@10Chunk đúng đầu tiên lên gần đầu khôngTốt cho câu hỏi có một đáp án chính
nDCG@10Ranking nhiều mức relevance có tốt khôngTốt khi qrels có điểm 0/1/2/3
Context precisionContext đưa vào prompt ít noise khôngLiên quan trực tiếp đến faithfulness
p50/p95/p99 latencyCó đạt SLA khôngLuôn đo riêng search, rerank, LLM

Nếu Recall@50 thấp, reranker không phải giải pháp chính. Cần xem lại chunking, query normalization, BM25, embedding model, metadata filter hoặc RRF.

9. Code Python gần production

Ví dụ dưới đây tập trung vào layer reranking. Hàm hybrid_retrieve được giả định đến từ pipeline Day 36.

from __future__ import annotations

import logging
import os
import time
from dataclasses import dataclass
from typing import Callable, Protocol, Sequence

logger = logging.getLogger(__name__)


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


@dataclass(frozen=True)
class CandidateChunk:
    id: str
    document_id: str
    chunk_id: str
    tenant_id: str
    acl_roles: frozenset[str]
    title: str
    section_path: tuple[str, ...]
    text: str
    source_uri: str
    page_start: int | None
    page_end: int | None
    retrieval_score: float
    retrieval_rank: int
    retriever: str

    def text_for_reranker(self, max_chars: int = 2_500) -> str:
        section = " > ".join(self.section_path)
        body = self.text[:max_chars]
        return (
            f"Title: {self.title}\n"
            f"Section: {section}\n"
            f"Source: {self.source_uri}\n"
            f"Text: {body}"
        )


@dataclass(frozen=True)
class RankedChunk:
    chunk: CandidateChunk
    rerank_score: float
    final_rank: int


class Reranker(Protocol):
    name: str
    model_version: str

    def score(self, query: str, candidates: Sequence[CandidateChunk]) -> list[float]:
        """Return one relevance score per candidate, same order as input."""


class BgeCrossEncoderReranker:
    name = "bge-cross-encoder"

    def __init__(
        self,
        model_name: str = "BAAI/bge-reranker-v2-m3",
        max_length: int = 512,
        device: str | None = None,
    ) -> None:
        from sentence_transformers import CrossEncoder

        self.model_version = model_name
        self.model = CrossEncoder(model_name, max_length=max_length, device=device)

    def score(self, query: str, candidates: Sequence[CandidateChunk]) -> list[float]:
        pairs = [(query, candidate.text_for_reranker()) for candidate in candidates]
        scores = self.model.predict(pairs)
        return [float(score) for score in scores]


class CohereReranker:
    name = "cohere-rerank"

    def __init__(
        self,
        api_key: str | None = None,
        model: str = "rerank-v4.0-pro",
        max_tokens_per_doc: int = 1_200,
    ) -> None:
        import cohere

        self.model_version = model
        self.client = cohere.Client(token=api_key or os.environ["COHERE_API_KEY"])
        self.max_tokens_per_doc = max_tokens_per_doc

    def score(self, query: str, candidates: Sequence[CandidateChunk]) -> list[float]:
        documents = [candidate.text_for_reranker() for candidate in candidates]
        response = self.client.v2.rerank(
            model=self.model_version,
            query=query,
            documents=documents,
            top_n=len(documents),
            max_tokens_per_doc=self.max_tokens_per_doc,
        )

        scores = [float("-inf")] * len(candidates)
        for result in response.results:
            scores[result.index] = float(result.relevance_score)
        return scores


HybridRetrieveFn = Callable[[str, int], list[CandidateChunk]]


def has_access(candidate: CandidateChunk, access: AccessContext) -> bool:
    if candidate.tenant_id != access.tenant_id:
        return False
    return bool(candidate.acl_roles & access.roles)


def dedupe_candidates(candidates: Sequence[CandidateChunk]) -> list[CandidateChunk]:
    best_by_chunk: dict[str, CandidateChunk] = {}
    for candidate in candidates:
        previous = best_by_chunk.get(candidate.id)
        if previous is None or candidate.retrieval_score > previous.retrieval_score:
            best_by_chunk[candidate.id] = candidate

    return sorted(
        best_by_chunk.values(),
        key=lambda item: (item.retrieval_rank, -item.retrieval_score),
    )


def fallback_rank(candidates: Sequence[CandidateChunk], final_k: int) -> list[RankedChunk]:
    return [
        RankedChunk(chunk=candidate, rerank_score=float("nan"), final_rank=index + 1)
        for index, candidate in enumerate(candidates[:final_k])
    ]


def retrieve_then_rerank(
    *,
    query: str,
    access: AccessContext,
    hybrid_retrieve: HybridRetrieveFn,
    reranker: Reranker,
    retrieve_k: int = 100,
    rerank_k: int = 50,
    final_k: int = 8,
    rerank_timeout_ms: int = 800,
) -> list[RankedChunk]:
    started = time.perf_counter()

    raw_candidates = hybrid_retrieve(query, retrieve_k)
    permitted = [item for item in raw_candidates if has_access(item, access)]
    deduped = dedupe_candidates(permitted)
    rerank_input = deduped[:rerank_k]

    if not rerank_input:
        return []

    try:
        scores = reranker.score(query, rerank_input)
    except Exception:
        logger.exception(
            "reranker_failed",
            extra={
                "reranker": reranker.name,
                "model_version": reranker.model_version,
                "retrieve_k": retrieve_k,
                "rerank_k": rerank_k,
            },
        )
        return fallback_rank(rerank_input, final_k)

    elapsed_ms = (time.perf_counter() - started) * 1000
    if elapsed_ms > rerank_timeout_ms:
        logger.warning(
            "reranker_slow",
            extra={
                "elapsed_ms": round(elapsed_ms, 2),
                "timeout_ms": rerank_timeout_ms,
                "reranker": reranker.name,
            },
        )
        return fallback_rank(rerank_input, final_k)

    ranked_pairs = sorted(
        zip(rerank_input, scores, strict=True),
        key=lambda item: item[1],
        reverse=True,
    )

    return [
        RankedChunk(chunk=chunk, rerank_score=float(score), final_rank=index + 1)
        for index, (chunk, score) in enumerate(ranked_pairs[:final_k])
    ]

Production note cho code trên:

  • hybrid_retrieve phải filter tenant_id, deleted_at, index_version càng sớm càng tốt ở database/search engine.
  • Reranker không được nhận candidate chưa qua ACL.
  • Với self-host BGE, nên expose qua service riêng để có request timeout thực sự, batching và autoscale. Skeleton trên chỉ minh họa timeout budget ở application layer.
  • Với managed API, đặt timeout/retry/circuit breaker ở HTTP client hoặc SDK layer.
  • Log cần có query_id, candidate_count, reranker_model, before_rank, after_rank, latency và fallback flag.

10. Evaluation code

from collections.abc import Iterable


def recall_at_k(ranked_ids: Sequence[str], relevant_ids: set[str], k: int) -> float:
    if not relevant_ids:
        return 0.0
    retrieved = set(ranked_ids[:k])
    return len(retrieved & relevant_ids) / len(relevant_ids)


def mrr_at_k(ranked_ids: Sequence[str], relevant_ids: set[str], k: int) -> float:
    for rank, chunk_id in enumerate(ranked_ids[:k], start=1):
        if chunk_id in relevant_ids:
            return 1.0 / rank
    return 0.0


def evaluate_ranker(
    run: dict[str, list[str]],
    qrels: dict[str, set[str]],
    recall_k: int = 5,
    mrr_k: int = 10,
) -> dict[str, float]:
    recall_scores = []
    mrr_scores = []

    for query_id, relevant_ids in qrels.items():
        ranked_ids = run.get(query_id, [])
        recall_scores.append(recall_at_k(ranked_ids, relevant_ids, recall_k))
        mrr_scores.append(mrr_at_k(ranked_ids, relevant_ids, mrr_k))

    total = max(len(qrels), 1)
    return {
        f"Recall@{recall_k}": sum(recall_scores) / total,
        f"MRR@{mrr_k}": sum(mrr_scores) / total,
    }

Cách chạy eval đúng:

  1. Tạo baseline hybrid_only_run: danh sách chunk_id sau RRF, chưa rerank.
  2. Tạo hybrid_rerank_run: danh sách chunk_id sau rerank.
  3. So sánh Recall@5, MRR@10 và latency cùng query set.
  4. Tách query theo tag: keyword-heavy, semantic, acronym, no-diacritic, policy version, tiếng Việt/English mix.
  5. Đọc ít nhất 5 query improved và 5 query regressed để biết vì sao metric đổi.

11. Production readiness

Dùng reranker trong production được không? Có, nếu thỏa các điều kiện sau:

  • Có eval set đại diện và reranker cải thiện metric quan trọng, ví dụ Recall@5 hoặc MRR@10, không chỉ cải thiện demo.
  • Candidate pool trước rerank có recall đủ cao, thường đo Recall@50 hoặc Recall@100.
  • ACL, tenant, deleted document và index version được filter trước rerank.
  • Latency p95/p99 sau khi thêm rerank vẫn nằm trong SLA.
  • Có timeout, fallback về retrieval rank và monitoring lỗi.
  • Có quyết định rõ managed vs self-host dựa trên privacy, cost, traffic và năng lực vận hành.
  • Có versioning cho reranker model, prompt/context builder và config retrieve_k, rerank_k, final_k.
  • Có regression test cho citation correctness và các query rủi ro cao.

Nếu chưa có eval set, chưa có ACL filter hoặc không đo latency percentile, chưa nên bật reranker mặc định cho production traffic. Có thể bật bằng feature flag cho internal users hoặc shadow traffic để thu số liệu.

12. Checklist cuối bài

  • Giải thích được vì sao retrieval top-k chưa đủ cho RAG production.
  • Phân biệt được bi-encoder và cross-encoder bằng latency, cost và quality.
  • Mô tả được BGE reranker và Cohere Rerank ở mức concept lẫn trade-off.
  • Thiết kế được pipeline retrieve top 50/100 -> rerank top 5/10.
  • Biết đặt ACL filter trước rerank.
  • Đo được Recall@k và MRR trước/sau rerank.
  • Có latency p50/p95/p99 cho search, rerank và total retrieval.
  • Có fallback khi reranker fail hoặc quá chậm.
  • Trả lời được production readiness bằng điều kiện cụ thể, không trả lời chung chung.

Tài liệu

1. Mental model nhanh

Reranking là bước xếp hạng lại một candidate pool nhỏ hơn sau retrieval.

Hybrid retrieval = recall layer
Reranker         = precision layer
Context builder  = token/citation layer
LLM              = answer layer

Ba câu cần hỏi trước khi thêm reranker:

  1. Candidate pool trước rerank có chứa chunk đúng chưa?
  2. Reranker có đưa chunk đúng lên top context tốt hơn không?
  3. Latency/cost tăng thêm có đáng với chất lượng tăng thêm không?

2. Decision matrix

ContextLựa chọn hợp lýLý do
Prototype hoặc eval nhanhCohere Rerank hoặc managed rerank APIÍt vận hành, tạo baseline nhanh
Dữ liệu nhạy cảm, không được gửi ra ngoàiSelf-host BGE rerankerKiểm soát privacy và network boundary
Traffic thấp, quality quan trọngCross-encoder rerank top 50/100Latency tăng nhưng chấp nhận được
Traffic cao, SLA chặtRerank top 20/50 hoặc conditional rerankGiữ p95 ổn định hơn
Corpus nhỏ, retrieval đã rất tốtCó thể chưa cần rerankerTránh thêm complexity khi metric không tăng
Legal/compliance/support policyNên rerank và eval kỹCần top context chính xác, citation đúng

3. Cấu hình khởi điểm

Tham sốGiá trị khởi điểmKhi tăngKhi giảm
bm25_top_k50Query nhiều keyword, cần recall caoBM25 nhiều nhiễu
vector_top_k50Semantic query đa dạngLatency search cao
rrf_k60Muốn giảm ảnh hưởng rank đầuÍt khi cần đổi sớm
rerank_k50Recall@50 tốt và cần top precision hơnp95/cost cao
final_k5-10Câu trả lời cần nhiều evidencePrompt bị noise hoặc quá dài
max_tokens_per_doc512-1200Chunk ngắn bị thiếu contextReranker chậm hoặc truncate sai
rerank_timeout_ms500-1500SLA rộng, quality ưu tiênChat cần phản hồi nhanh

Không copy cấu hình này vào production mà không benchmark. Nó là điểm bắt đầu để chạy eval.

4. Metrics bắt buộc

MetricNơi đoÝ nghĩa
Recall@50 before rerankCandidate poolRetriever có tìm thấy chunk đúng không
Recall@5 after rerankFinal contextReranker có đưa chunk đúng vào prompt không
MRR@10Final rankingChunk đúng đầu tiên đứng cao không
nDCG@10Final rankingRanking có tôn trọng relevance nhiều mức không
Context precisionContext builderPrompt ít chunk nhiễu không
Citation correctnessAnswer auditCâu trả lời cite đúng nguồn không
p50/p95/p99 rerank latencyRuntimeCó đạt SLA không
Fallback rateRuntimeReranker fail/timeout nhiều không
Cost per 1K queriesFinance/opsCó scale được theo traffic không

5. Eval report template

| Pipeline | Recall@5 | MRR@10 | nDCG@10 | p95 search ms | p95 rerank ms | p95 total ms | Cost/1K | Note |
|---|---:|---:|---:|---:|---:|---:|---:|---|
| Hybrid only | | | | | 0 | | | |
| Hybrid + BGE rerank top 50 | | | | | | | | |
| Hybrid + Cohere rerank top 50 | | | | | | | | |
| Hybrid + rerank top 100 | | | | | | | | |

Phân tích bắt buộc sau bảng:

  • 5 query improved: vì sao reranker giúp?
  • 5 query regressed: vì sao reranker làm tệ hơn?
  • Nhóm query nào hưởng lợi nhiều nhất?
  • Nhóm query nào cần sửa chunking/retrieval thay vì rerank?
  • Cấu hình nào đạt quality tốt nhất trong latency budget?

6. Runbook rollout

  1. Chốt query eval set và qrels.
  2. Chạy baseline Hybrid Search từ Day 36.
  3. Thêm reranker phía sau RRF, phía sau ACL filter.
  4. Chạy offline eval với nhiều cấu hình rerank_k: 20, 50, 100.
  5. Benchmark latency với độ dài chunk thật.
  6. Chạy security test: tenant A không thể rerank chunk tenant B.
  7. Shadow traffic: log ranking mới nhưng chưa dùng để trả lời user.
  8. So sánh answer quality và citation correctness.
  9. Bật feature flag cho internal users hoặc một phần traffic.
  10. Theo dõi p95/p99, fallback rate, cost và complaint rate.
  11. Rollback nếu latency vượt SLA hoặc regression ở query rủi ro cao.

7. Observability

Log mỗi request nên có:

  • query_id, tenant_id, user_role_group.
  • retriever_versions: BM25 config, embedding model, index version.
  • retrieve_k, rerank_k, final_k.
  • Candidate ids trước rerank và sau rerank.
  • Retrieval score, RRF rank, reranker score.
  • Reranker provider, model version, timeout, retry count.
  • Latency từng stage: normalize, BM25, vector, RRF, ACL filter, rerank, context build.
  • Fallback reason nếu có.

Không log raw query hoặc raw chunk nếu chứa PII mà chưa có policy. Có thể log hash, redacted text hoặc sample theo allowlist.

8. Failure modes

LỗiDấu hiệuCách xử lý
Candidate pool thiếu chunk đúngRecall@50 thấpSửa retrieval, chunking, embedding, BM25 hoặc RRF
Reranker làm tụt rankingMRR giảm, query regressed nhiềuĐổi model, format input, fine-tune hoặc threshold
ACL leakCandidate không đúng quyền xuất hiện trong log/API/promptFilter trước rerank, thêm regression test
Latency spikep95/p99 tăng mạnhGiảm rerank_k, batch tốt hơn, timeout/fallback
Cost spikeCost/1K queries tăngConditional rerank, cache, giảm candidate/text length
Score khó hiểuThreshold fail khi đổi modelCalibrate score theo eval set, không xem score là confidence
Citation saiAnswer cite nhầm sourceGiữ stable chunk_id, source_uri, page metadata qua mọi stage

9. Managed vs self-host checklist

Managed API phù hợp khi:

  • Team cần ship nhanh.
  • Data policy cho phép gửi query/chunk ra provider.
  • Traffic chưa quá lớn hoặc cost dự đoán được.
  • SLA của provider và rate limit phù hợp.

Self-host phù hợp khi:

  • Dữ liệu nhạy cảm hoặc yêu cầu data residency chặt.
  • Traffic đủ lớn để tối ưu cost bằng hạ tầng riêng.
  • Team có năng lực vận hành CPU/GPU inference.
  • Cần fine-tune hoặc kiểm soát model version chặt.

10. Production readiness checklist

  • Có qrels và eval set đại diện.
  • Có baseline Hybrid Search trước khi thêm reranker.
  • Recall@50 hoặc Recall@100 trước rerank đủ cao.
  • Reranker cải thiện Recall@5, MRR@10 hoặc metric mục tiêu.
  • Đã đo latency p50/p95/p99 bằng dữ liệu thật.
  • Đã lọc tenant, ACL, deleted, index version trước rerank.
  • Có timeout và fallback về retrieval rank.
  • Có feature flag và rollback path.
  • Có model/config versioning.
  • Có cost estimate cho traffic hiện tại và 3-6 tháng tới.
  • Có policy logging cho PII.
  • Có citation correctness test.

11. Quiz nhanh

  1. Vì sao reranker không thể sửa lỗi khi chunk đúng không nằm trong top 50 candidate?
  2. Khi nào nên dùng cross-encoder thay vì chỉ dùng bi-encoder?
  3. Vì sao phải filter ACL trước khi gọi managed rerank API?
  4. Nếu Recall@50 thấp nhưng MRR@10 sau rerank cao, bạn kết luận gì?
  5. Nếu MRR tăng nhưng p95 latency vượt SLA, bạn thử những thay đổi nào trước?

Đáp án gợi ý:

  1. Reranker chỉ sắp xếp candidate có sẵn, không search lại toàn corpus.
  2. Khi top context precision, citation quality hoặc câu hỏi có nuance quan trọng hơn latency tăng thêm.
  3. Vì gửi chunk không đúng quyền ra ngoài đã là data leak, dù sau đó không đưa vào prompt.
  4. Reranker tốt trên candidate có sẵn, nhưng retrieval vẫn bỏ sót nhiều đáp án. Cần cải thiện retrieval/chunking.
  5. Giảm rerank_k, dedupe tốt hơn, truncate text, batching, cache, conditional rerank hoặc đổi model nhỏ hơn.

12. Câu trả lời production readiness

Reranker dùng được trong production, nhưng không nên bật chỉ vì "nghe có vẻ tốt hơn". Quyết định production cần dựa trên eval và SLA:

  • Nếu metric tăng rõ, latency/cost nằm trong budget, privacy được xử lý và có fallback, nên dùng.
  • Nếu metric không tăng hoặc candidate recall thấp, hãy sửa retrieval trước.
  • Nếu data policy không cho gửi dữ liệu ra ngoài, chỉ dùng managed API khi có approval rõ, còn lại self-host.
  • Nếu p95 vượt SLA, dùng conditional rerank hoặc giảm candidate thay vì hy sinh toàn bộ trải nghiệm chat.

Bài tập

Mục tiêu

Sau bài tập này bạn sẽ có một pipeline RAG retrieval gần production:

BM25 top 50 + Vector top 50
  -> RRF merge
  -> ACL filter
  -> dedupe
  -> rerank top 50
  -> final top 5/10
  -> evaluate Recall@k / MRR

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

1. Chuẩn bị

Yêu cầu:

  • Python 3.10+.
  • Pipeline Day 36 đã có BM25, vector search và RRF.
  • Một corpus nhỏ có chunk_id, document_id, tenant_id, acl_roles, title, text, source_uri.
  • Một query set có qrels.

Cài thư viện nếu muốn chạy BGE reranker local:

pip install sentence-transformers numpy

Nếu muốn thử Cohere Rerank:

pip install cohere
export COHERE_API_KEY="..."

2. Dataset và qrels tối thiểu

Tạo file hoặc object Python tương đương:

QUERIES = [
    {
        "query_id": "q001",
        "query": "Nhân viên full-time có bao nhiêu ngày nghỉ phép năm 2026?",
        "tags": ["policy", "version", "vietnamese"],
    },
    {
        "query_id": "q002",
        "query": "VPN error 809 xử lý như thế nào?",
        "tags": ["keyword-heavy", "incident"],
    },
    {
        "query_id": "q003",
        "query": "Can contractor access production database?",
        "tags": ["english", "acl", "security"],
    },
]

QRELS = {
    "q001": {"hr_leave_2026:chunk_003"},
    "q002": {"it_vpn_runbook:chunk_007"},
    "q003": {"security_access_policy:chunk_011"},
}

Mở rộng lên ít nhất 30 query nếu muốn kết quả có ý nghĩa hơn. Nên có query tốt, query khó, query tiếng Việt không dấu, acronym, mã lỗi, và query trộn English/Vietnamese.

Từ Day 36, viết hàm trả về danh sách chunk_id sau RRF, chưa rerank.

def run_hybrid_only(query: str, top_k: int = 10) -> list[str]:
    bm25_hits = bm25_search(query, top_k=50)
    vector_hits = vector_search(query, top_k=50)
    merged = reciprocal_rank_fusion([bm25_hits, vector_hits])
    permitted = acl_filter(merged)
    deduped = dedupe(permitted)
    return [hit.chunk_id for hit in deduped[:top_k]]

Yêu cầu:

  • Không có chunk khác tenant.
  • Không có chunk user không có quyền.
  • Có log số lượng candidate sau từng bước.

4. Step 2 - Thêm BGE reranker

from sentence_transformers import CrossEncoder


class LocalBgeReranker:
    def __init__(self, model_name: str = "BAAI/bge-reranker-v2-m3") -> None:
        self.model = CrossEncoder(model_name, max_length=512)

    def rerank(self, query: str, candidates: list[dict], top_n: int = 10) -> list[dict]:
        pairs = [
            (
                query,
                "Title: {title}\nSection: {section}\nText: {text}".format(
                    title=item.get("title", ""),
                    section=" > ".join(item.get("section_path", [])),
                    text=item["text"][:2500],
                ),
            )
            for item in candidates
        ]
        scores = self.model.predict(pairs)

        ranked = sorted(
            zip(candidates, scores, strict=True),
            key=lambda item: float(item[1]),
            reverse=True,
        )
        return [
            {
                **candidate,
                "rerank_score": float(score),
                "final_rank": index + 1,
            }
            for index, (candidate, score) in enumerate(ranked[:top_n])
        ]

Pipeline:

def run_hybrid_plus_rerank(query: str, reranker: LocalBgeReranker) -> list[str]:
    bm25_hits = bm25_search(query, top_k=50)
    vector_hits = vector_search(query, top_k=50)
    merged = reciprocal_rank_fusion([bm25_hits, vector_hits])
    permitted = acl_filter(merged)
    deduped = dedupe(permitted)

    candidates = [hit.to_dict() for hit in deduped[:50]]
    ranked = reranker.rerank(query, candidates, top_n=10)
    return [item["chunk_id"] for item in ranked]

Nếu máy yếu, giảm rerank_k xuống 20 trước, sau đó benchmark lại top 50.

5. Step 3 - Optional Cohere Rerank

Dùng managed API để so sánh baseline nhanh:

import os

import cohere


class CohereRerankClient:
    def __init__(self, model: str = "rerank-v4.0-pro") -> None:
        self.client = cohere.Client(token=os.environ["COHERE_API_KEY"])
        self.model = model

    def rerank(self, query: str, candidates: list[dict], top_n: int = 10) -> list[dict]:
        documents = [
            "Title: {title}\nText: {text}".format(
                title=item.get("title", ""),
                text=item["text"][:2500],
            )
            for item in candidates
        ]
        response = self.client.v2.rerank(
            model=self.model,
            query=query,
            documents=documents,
            top_n=top_n,
            max_tokens_per_doc=1200,
        )

        ranked = []
        for rank, result in enumerate(response.results, start=1):
            item = dict(candidates[result.index])
            item["rerank_score"] = float(result.relevance_score)
            item["final_rank"] = rank
            ranked.append(item)
        return ranked

Chỉ chạy phần này nếu dữ liệu được phép gửi ra ngoài. Nếu corpus có PII hoặc dữ liệu nội bộ nhạy cảm, dùng dữ liệu giả lập hoặc bỏ qua managed API.

6. Step 4 - Evaluation

def recall_at_k(ranked_ids: list[str], relevant_ids: set[str], k: int) -> float:
    if not relevant_ids:
        return 0.0
    return len(set(ranked_ids[:k]) & relevant_ids) / len(relevant_ids)


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


def evaluate(run: dict[str, list[str]], qrels: dict[str, set[str]]) -> dict[str, float]:
    recall_5 = []
    mrr_10 = []
    for query_id, relevant_ids in qrels.items():
        ranked_ids = run.get(query_id, [])
        recall_5.append(recall_at_k(ranked_ids, relevant_ids, 5))
        mrr_10.append(mrr_at_k(ranked_ids, relevant_ids, 10))

    total = len(qrels) or 1
    return {
        "Recall@5": sum(recall_5) / total,
        "MRR@10": sum(mrr_10) / total,
    }

Chạy:

hybrid_run = {
    item["query_id"]: run_hybrid_only(item["query"], top_k=10)
    for item in QUERIES
}

reranker = LocalBgeReranker()
rerank_run = {
    item["query_id"]: run_hybrid_plus_rerank(item["query"], reranker)
    for item in QUERIES
}

print("Hybrid only:", evaluate(hybrid_run, QRELS))
print("Hybrid + rerank:", evaluate(rerank_run, QRELS))

7. Step 5 - Benchmark latency

import statistics
import time


def benchmark(fn, queries: list[dict], repeat: int = 3) -> dict[str, float]:
    latencies = []
    for _ in range(repeat):
        for item in queries:
            started = time.perf_counter()
            fn(item["query"])
            latencies.append((time.perf_counter() - started) * 1000)

    latencies = sorted(latencies)
    p95_index = int(0.95 * (len(latencies) - 1))
    return {
        "avg_ms": statistics.mean(latencies),
        "p50_ms": statistics.median(latencies),
        "p95_ms": latencies[p95_index],
        "max_ms": max(latencies),
    }

Đo riêng:

  • Hybrid only.
  • Hybrid + BGE rerank top 20.
  • Hybrid + BGE rerank top 50.
  • Optional Hybrid + Cohere rerank top 50.

8. Report cần nộp

| Pipeline | Recall@5 | MRR@10 | p50 ms | p95 ms | Cost/1K query | Ghi chú |
|---|---:|---:|---:|---:|---:|---|
| Hybrid only | | | | | | |
| Hybrid + BGE top 20 | | | | | | |
| Hybrid + BGE top 50 | | | | | | |
| Hybrid + Cohere top 50 | | | | | | |

Thêm phân tích:

  • 5 query improved sau rerank.
  • 5 query regressed sau rerank.
  • Query nào không được cải thiện vì chunk đúng không nằm trong candidate pool?
  • rerank_k=20 hay rerank_k=50 đáng dùng hơn theo SLA?
  • Nếu bật production, bạn dùng BGE self-host hay Cohere managed? Vì sao?

9. Quiz

  1. Vì sao cần retrieve top 50/100 rồi mới rerank top 5/10?
  2. Reranking cải thiện Recall@50 hay Recall@5? Giải thích.
  3. Nếu p95 tăng từ 450ms lên 1.600ms sau rerank, bạn xử lý thế nào?
  4. Nếu dùng Cohere Rerank cho tài liệu nội bộ, bạn phải kiểm tra những điều kiện gì?
  5. Khi nào không nên dùng reranker?

10. Acceptance criteria

  • Có pipeline Hybrid only chạy được.
  • Có pipeline Hybrid + rerank chạy được.
  • ACL filter chạy trước rerank.
  • Có số liệu Recall@5 và MRR@10 trước/sau rerank.
  • Có benchmark p50/p95.
  • Có bảng so sánh ít nhất 2 cấu hình rerank_k.
  • Có phân tích improved/regressed query.
  • Có câu trả lời production readiness rõ ràng.

11. Câu trả lời production readiness mẫu

Ví dụ câu trả lời tốt:

Có thể dùng reranker trong production cho nhóm query policy/support vì Hybrid + BGE top 50 tăng MRR@10 từ 0.61 lên 0.78 và Recall@5 từ 0.70 lên 0.84 trên 120 query eval. p95 retrieval tăng từ 420ms lên 780ms, vẫn dưới SLA 1.200ms. Hệ thống đã filter tenant/ACL trước rerank, có timeout 800ms và fallback về RRF rank. Giai đoạn đầu bật bằng feature flag cho 20% internal traffic, log before/after rank và theo dõi citation correctness.

Ví dụ câu trả lời chưa đạt:

Nên dùng reranker vì nó thông minh hơn vector search.

Câu trả lời chưa đạt vì không có metric, latency, security condition, fallback và rollout plan.