Một hệ thống RAG cơ bản thường xuyên gặp các lỗi nghiêm trọng trong môi trường doanh nghiệp: người dùng đặt câu hỏi quá ngắn hoặc sai chính tả khiến tìm kiếm vector bị lệch hướng, hoặc thông tin trả lời đúng bị chôn ở cuối danh sách kết quả dẫn tới việc LLM hoàn toàn bỏ qua.

Bài 15 cải thiện cách lưu và cách tìm. Bài này xử lý hai đầu còn lại: đầu vào (câu hỏi thô của người dùng) và đầu ra của pha truy xuất (thứ hạng). Chúng ta dựng Query Rewriting rồi đo xem nó cứu được bao nhiêu, cài đặt Cross-Encoder Reranking thật — không phải bằng phép đếm từ — để thấy nó khác Bi-Encoder ở chỗ nào và tốn kém đến đâu, và cuối cùng triển khai Parent-Child Indexing để tách vùng tìm khỏi vùng đọc.

✅ Bạn cần gì trước khi bắt đầu
Phần mềm: đây là bài đầu tiên cần cả hai loại mô hình cùng lúc — một mô hình chat (ollama pull qwen2.5:7b) và một mô hình nhúng (ollama pull bge-m3). Không có cả hai thì chương trình dừng ngay và nói rõ thiếu cái nào, chứ không chạy tiếp trong im lặng.

Kiến thức cần có: Bài 14 cho đường ống RAG và Cosine Similarity; Bài 15 cho khái niệm truy xuất hai pha và lý do phải xếp hạng; Bài 11 cho Few-shot Prompting — mục 16.1 dùng đúng kỹ thuật đó để ghìm hành vi của mô hình viết lại câu hỏi.
⚡ Bài này không giả lập gì cả — và đó là chủ ý
Bài 14 và 15 dùng phép đếm từ để minh họa cho gọn. Ở bài này thì không thể làm vậy: toàn bộ nội dung là sự khác nhau giữa Bi-Encoder và Cross-Encoder, mà cả hai đều là mạng nơ-ron. Nếu thay cả hai bằng phép đếm từ trùng thì hai tầng sẽ đo cùng một thứ, và bài học biến mất. Vì vậy Bi-Encoder ở đây là mô hình nhúng thật, còn Cross-Encoder là mô hình ngôn ngữ thật chấm điểm câu hỏi và tài liệu cùng lúc.

16.1 Tối ưu hóa câu hỏi: Kỹ thuật Query Rewriting

Người dùng thực tế không viết các câu hỏi có cấu trúc ngữ nghĩa hoàn chỉnh giống hệt tài liệu lưu trữ. Họ thường đặt câu hỏi cực ngắn (ví dụ: "chế độ thai sản"), sai chính tả, gõ không dấu, hoặc dùng đại từ không xác định ("nó hoạt động thế nào?").

Kỹ thuật Query Rewriting (Viết lại truy vấn) đặt một mô hình ngôn ngữ nhỏ làm bộ lọc đầu vào, với nhiệm vụ:

  • Sửa lỗi: Tự động sửa lỗi chính tả và chuẩn hóa thuật ngữ chuyên ngành.
  • Bổ nghĩa ngữ cảnh: Dựa vào lịch sử hội thoại trước đó để bổ sung các danh từ bị ẩn (ví dụ: chuyển từ "nó" sang tên sản phẩm cụ thể).
  • Mở rộng truy vấn (Query Expansion): Tạo ra 3-4 biến thể câu hỏi khác nhau có cùng ý nghĩa để quét đồng thời vào Vector DB.
⚠️ Cạm bẫy: Hiện tượng lệch hướng ý định (Intent Drift)
Nếu prompt cho mô hình viết lại không đủ chặt, nó có thể "sáng tạo" và đổi câu hỏi theo một hướng xa lạ so với ý định gốc. Giải pháp là Few-shot Prompting (Bài 11) — đưa sẵn vài cặp câu hỏi thô và câu viết lại chuẩn để ghìm hành vi mô hình.

Và bạn sẽ thấy hiện tượng này ngay trong kết quả chạy bên dưới: câu hỏi thô "nghi om can nhan slack luc nao" được viết lại thành "Khi nào cần nhận Slack để báo nghỉ ốm?" — mô hình đọc "nhan" không dấu thành "nhận" thay vì "nhắn", làm đổi hẳn chiều của hành động (nhận tin thay vì gửi tin). Câu viết lại vẫn giúp tìm đúng tài liệu, nhưng đây chính xác là loại trôi nghĩa mà bạn phải canh chừng — và nó tự xảy ra, không phải ví dụ tôi bịa ra.

Câu hỏi thực tế là: viết lại giúp được bao nhiêu? Dự án đo độ khớp giữa câu hỏi (thô và đã viết lại) với đúng tài liệu chứa câu trả lời, theo cả hai cách đo:

Terminal
=== Stage 1: query rewriting ===
  raw       : nghi om can nhan slack luc nao
  rewritten : Khi nào cần nhận Slack để báo nghỉ ốm?
  match against doc2 (the correct document):
    bag-of-words : 0.0845 -> 0.2981   (3.5x)
    embeddings   : 0.4465 -> 0.7817   (1.8x)
  Rewriting rescues keyword search; it only tunes semantic search.

Hai dòng số này nói một điều mà hầu hết bài viết về Query Rewriting bỏ qua: mức lợi phụ thuộc vào bộ truy xuất bạn đang dùng. Với tìm kiếm từ khóa, câu hỏi thô đạt $0.0845$ — gần như nhiễu, nó chỉ thắng vì các tài liệu khác về 0. Viết lại đẩy lên $0.2981$, gấp 3.5 lần: đây là cứu hộ, không phải tối ưu. Với vector nhúng thật, câu hỏi thô đã đạt $0.4465$ — mô hình nhúng vốn chịu được chính tả sai và mất dấu khá tốt — nên viết lại chỉ nâng lên $0.7817$, gấp $1.8$ lần: dễ chịu, nhưng không phải chuyện sống còn.

Kết luận thực dụng: nếu hệ thống của bạn còn dùng BM25 hay TF-IDF (Bài 14, 15), Query Rewriting là bước đáng giá nhất bạn có thể thêm vào. Nếu đã chạy embedding tốt rồi, hãy cân nhắc — mỗi lần viết lại là một lượt gọi LLM nữa đứng chắn trước mọi câu hỏi, và nó có thể trôi nghĩa như ví dụ ở trên.

16.2 Bộ sắp xếp lại kết quả: Reranking với Cross-Encoder

Để hiểu tại sao cần Reranker, phải phân biệt rõ cấu trúc hoạt động của Bi-Encoder (Vector DB)Cross-Encoder (Reranker).

⚖️ So sánh Bi-Encoder vs Cross-Encoder:

  • Bi-Encoder (Tìm kiếm Vector DB): Tính vector biểu nghĩa của câu hỏi và của tài liệu một cách hoàn toàn độc lập với nhau. Điểm tương đồng là phép nhân vô hướng giữa hai vector đã đóng băng. Ưu điểm: cực nhanh, và vector tài liệu tính sẵn một lần rồi dùng mãi. Nhược điểm: bỏ qua mối quan hệ tương tác từ-với-từ giữa hai câu văn.
  • Cross-Encoder (Reranker): Đưa đồng thời cả câu hỏi và tài liệu vào chung một mạng nơ-ron để chúng tự do tương tác qua cơ chế Self-Attention của Transformer (Bài 10). Ưu điểm: cực nhạy với sự liên quan chi tiết. Nhược điểm: không tính sẵn được — mỗi cặp (câu hỏi, tài liệu) là một lượt chạy mô hình riêng.

Câu cuối cùng là toàn bộ lý do kiến trúc hai tầng tồn tại. Bi-Encoder tính vector tài liệu một lần lúc nạp dữ liệu, nên lúc có câu hỏi nó chỉ còn so vector. Cross-Encoder không có gì để tính sẵn: nó cần nhìn thấy câu hỏi và tài liệu cùng lúc, nên chi phí nhân với số tài liệu.

Quy trình chuẩn phối hợp hai mô hình (Two-stage Retrieval):

1 Triệu Documents ➔ Bi-Encoder (Vector DB) ➔ Top 25 Documents ➔ Cross-Encoder (Reranker) ➔ Top 3 Best Docs

Đo thử: Cross-Encoder khác Bi-Encoder ở chỗ nào, và tốn bao nhiêu?

Kho tài liệu của dự án gồm 7 văn bản đều nói về quy định nghỉ phép — cố ý như vậy, vì một kho 5 tài liệu về 5 chủ đề khác nhau thì truy xuất nào cũng đúng và chẳng chứng minh được gì. Câu hỏi: "nghỉ ốm thì phải báo trước mấy giờ?"

Terminal
=== Stage 2: bi-encoder retrieval, then cross-encoder reranking ===
  bi-encoder top 4 (1.18s for 7 documents)
    doc2 0.680  Khi bị ốm đột xuất, nhân viên cần nhắn Slack cho q
    doc1 0.650  Nhân viên xin nghỉ phép năm phải khai báo trên HR-
    doc3 0.635  Nghỉ phép dài trên 5 ngày phải được Giám đốc điều
    doc7 0.617  Giấy xác nhận của bác sĩ phải được nộp cho bộ phận
    spread across all four: 0.062; lead over the runner-up: 0.030

  cross-encoder rescoring (0.93s for 4 documents)
    doc2  7/10  Khi bị ốm đột xuất, nhân viên cần nhắn Slack cho q
    doc1  2/10  Nhân viên xin nghỉ phép năm phải khai báo trên HR-
    doc3  0/10  Nghỉ phép dài trên 5 ngày phải được Giám đốc điều
    doc7  0/10  Giấy xác nhận của bác sĩ phải được nộp cho bộ phận
    lead over the runner-up: 5 points

  cost per document: 0.23s. Running the cross-encoder over
  all 7 documents would take 1.6s; over a million it is
  64 hours. That is why stage 1 exists.

Bi-Encoder xếp đúng thứ tự — doc2 đứng đầu. Nhưng hãy nhìn khoảng cách: bốn tài liệu nằm trong dải $0.617$ đến $0.680$, tức toàn bộ độ chênh chỉ có $0.062$, và tài liệu đúng chỉ hơn tài liệu về nghỉ phép năm đúng $0.030$. Với mô hình nhúng, cả bốn văn bản này gần như "liên quan như nhau" — chúng đều nói về nghỉ phép, đều nhắc nhân viên và thủ tục. Chỉ cần kho tài liệu lớn hơn, hoặc câu hỏi lệch đi một chút, và thứ tự đó lật.

Cross-Encoder chấm 7 / 2 / 0 / 0. Nó không chỉ giữ đúng thứ tự mà còn tách bạch: hai tài liệu cuối bị đẩy về đúng 0 — không phải "hơi liên quan" mà là "không trả lời câu hỏi này". Đó là thứ Bi-Encoder về nguyên tắc không làm được: nó so hai vector đã đóng băng, không bao giờ được nhìn câu hỏi và tài liệu cạnh nhau.

🔢 Con số biện minh cho kiến trúc hai tầng
Cross-Encoder tốn 0.23 giây mỗi tài liệu trên máy viết bài này. Nghe không nhiều — với 7 tài liệu là 1.6 giây. Nhưng chi phí đó nhân thẳng với kích thước kho: một triệu tài liệu sẽ mất khoảng 64 giờ cho một câu hỏi duy nhất. Trong khi Bi-Encoder xử lý cả 7 tài liệu trong $1.18$ giây, và với kho thật thì phần lớn thời gian đó là tính vector cho câu hỏi, còn vector tài liệu đã tính sẵn từ lúc nạp dữ liệu.

Vì vậy sơ đồ ở trên không phải một quy ước tùy hứng: Bi-Encoder lọc một triệu xuống 25 trong vài mili-giây, rồi Cross-Encoder chỉ phải chạy 25 lần. Đảo thứ tự hai tầng là bất khả thi về mặt vật lý.

16.3 Parent-Child Indexing & Sentence-Window Retrieval

Một mâu thuẫn lớn trong thiết kế RAG:

  • Để mô hình Embeddings hóa vector chính xác nhất, ta cần chia văn bản thành các đoạn cực nhỏ (ví dụ: từng câu đơn lẻ) — đoạn càng ngắn thì vector càng tập trung vào một ý.
  • Nhưng để LLM có đủ ngữ cảnh để trả lời mạch lạc, ta cần một đoạn văn lớn bao quanh.

Các kiến trúc RAG nâng cao giải quyết bằng cách tách biệt vùng để tìmvùng để đọc:

  1. Parent-Child Indexing: Chia văn bản lớn (Parent) thành nhiều đoạn nhỏ (Child). Chỉ hóa vector và tìm kiếm trên các đoạn Child. Khi tìm thấy Child phù hợp nhất, hệ thống truy ngược ID để lấy toàn bộ đoạn Parent nạp vào ngữ cảnh của LLM.
  2. Sentence-Window Retrieval: Tìm kiếm vector trên từng câu độc lập. Khi tìm thấy câu khớp nhất, hệ thống lấy kèm $K$ câu đứng trước và $K$ câu đứng sau gửi cho LLM.

Dự án cài đặt Parent-Child trên một khối văn bản mô tả quy trình nghỉ ốm gồm ba bước, rồi hỏi cùng một câu với hai mức ngữ cảnh khác nhau — chỉ khác đúng chỗ đó:

Terminal
=== Stage 3: parent-child indexing ===
  indexed 4 child sentences from 1 parent block
  best child (0.680): Khi bị ốm đột xuất, nhân viên cần nhắn Slack cho quản lý trực tiếp trước 9h00 sáng cùng ngày.
  child length : 93 characters
  parent length: 295 characters
  The child is what gets matched; the parent is what the model reads.

=== Same question, child context versus parent context ===
  question: Quy trình nghỉ ốm gồm những bước nào?

  from the child only (93 chars):
    Dựa trên ngữ cảnh được cung cấp, quy trình nghỉ ốm bao gồm bước sau:

    1. Nhân viên cần nhắn Slack cho quản lý trực tiếp trước 9h00 sáng cùng ngày khi bị ốm đột xuất.

    Nếu có thêm các bước cụ thể khác không được đề cập trong ngữ cảnh này, câu trả lời sẽ là 'Không tìm thấy trong tài liệu'.

  from the parent block (295 chars):
    Quy trình nghỉ ốm của công ty gồm ba bước sau:

    1. Nhân viên cần nhắn Slack cho quản lý trực tiếp trước 9h00 sáng cùng ngày khi bị ốm đột xuất.
    2. Nếu nghỉ quá hai ngày liên tiếp, nhân viên phải báo thêm cho bộ phận nhân sự.
    3. Khi quay lại làm việc, giấy xác nhận của bác sĩ phải được nộp.

Cùng một câu hỏi, cùng một mô hình, cùng một câu Child được tìm ra. Khác biệt duy nhất là ngữ cảnh nạp vào: 93 ký tự so với 295 ký tự. Bản Child chỉ trả lời được bước 1 rồi phải rào đón; bản Parent trả lời trọn cả ba bước. Đáng chú ý là câu Child vẫn là câu đúng nhất để tìm — nó chứa đúng các từ khóa của câu hỏi. Vấn đề không nằm ở khâu tìm mà ở khâu đọc, và đó chính là điều Parent-Child tách ra.

16.4 Dự án thực hành bài 16: Đường ống RAG ba tầng

File gồm ba tầng khớp với ba mục trên, và đây là bài đầu tiên trong series mà mọi thành phần đều là mô hình thật: mô hình nhúng làm Bi-Encoder, mô hình ngôn ngữ làm cả bộ viết lại câu hỏi lẫn Cross-Encoder chấm điểm.

advanced_rag.py
"""Lesson 16 project: query rewriting, real reranking, and parent-child indexing.

Run:  python3 advanced_rag.py
Needs: Ollama with a chat model and an embedding model
       (`ollama pull qwen2.5:7b` and `ollama pull bge-m3`).

Unlike Lessons 14 and 15, nothing here is simulated with word counting. The
bi-encoder is a real embedding model and the cross-encoder is a real language
model scoring the query and the document together. That distinction is the
entire subject of the lesson, so faking it would defeat the point.
"""

import json
import math
import re
import time
import urllib.error
import urllib.request

OLLAMA = "http://localhost:11434"
# An instruct model first: a code-tuned model scores relevance far more harshly.
CHAT_PREFERRED = ["qwen2.5:14b-instruct", "qwen2.5:7b", "llama3.1", "llama3.2",
                  "qwen2.5-coder:7b"]
EMBED_PREFERRED = ["bge-m3", "nomic-embed-text", "mxbai-embed-large"]

# Seven documents that all talk about leave policy. A corpus of five unrelated
# topics would make retrieval trivially easy and prove nothing about reranking.
DOCUMENTS = [
    {"id": "doc1", "text": "Nhân viên xin nghỉ phép năm phải khai báo trên "
                           "HR-Portal trước ít nhất 3 ngày làm việc."},
    {"id": "doc2", "text": "Khi bị ốm đột xuất, nhân viên cần nhắn Slack cho "
                           "quản lý trực tiếp trước 9h00 sáng cùng ngày."},
    {"id": "doc3", "text": "Nghỉ phép dài trên 5 ngày phải được Giám đốc điều "
                           "hành ký duyệt trước khi nghỉ."},
    {"id": "doc4", "text": "Nhân viên nữ nghỉ thai sản được hưởng 6 tháng theo "
                           "quy định của Luật Bảo hiểm xã hội."},
    {"id": "doc5", "text": "Đơn xin nghỉ phép nộp muộn sẽ bị tính là nghỉ không "
                           "lương trong kỳ chấm công tháng đó."},
    {"id": "doc6", "text": "Nhân viên làm việc từ xa vẫn phải cập nhật trạng "
                           "thái trên Slack vào đầu mỗi ngày làm việc."},
    {"id": "doc7", "text": "Giấy xác nhận của bác sĩ phải được nộp cho bộ phận "
                           "nhân sự khi nhân viên quay lại làm việc sau kỳ nghỉ ốm."},
]

RAW_QUERY = "nghi om can nhan slack luc nao"


# ---------------------------------------------------------------------------
# Talking to Ollama - the pattern established in Lesson 13
# ---------------------------------------------------------------------------


def post(path, payload):
    request = urllib.request.Request(
        f"{OLLAMA}{path}", data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
    )
    try:
        with urllib.request.urlopen(request) as response:
            return json.loads(response.read())
    except urllib.error.HTTPError as exc:
        detail = json.loads(exc.read() or b"{}").get("error", "no detail")
        raise RuntimeError(f"Ollama answered HTTP {exc.code}: {detail}") from None
    except urllib.error.URLError as exc:
        raise RuntimeError(f"cannot reach Ollama at {OLLAMA} - {exc.reason}") from None


def installed_models():
    try:
        with urllib.request.urlopen(f"{OLLAMA}/api/tags", timeout=5) as response:
            return [m["name"] for m in json.loads(response.read())["models"]]
    except urllib.error.URLError:
        return []


def pick(names, preferred):
    for wanted in preferred:
        for name in names:
            if name == wanted or name.startswith(wanted):
                return name
    return None


def embed(text, model):
    return post("/api/embeddings", {"model": model, "prompt": text})["embedding"]


def chat(prompt, model, temperature=0.0):
    payload = {"model": model, "stream": False,
               "options": {"temperature": temperature},
               "messages": [{"role": "user", "content": prompt}]}
    return post("/api/chat", payload)["message"]["content"].strip()


# ---------------------------------------------------------------------------
# Similarity
# ---------------------------------------------------------------------------


def word_cosine(text1, text2):
    """Bag-of-words cosine: the keyword baseline from Lesson 14."""
    t1 = re.findall(r"\b\w+\b", text1.lower())
    t2 = re.findall(r"\b\w+\b", text2.lower())
    vocab = set(t1) | set(t2)
    v1 = [t1.count(w) for w in vocab]
    v2 = [t2.count(w) for w in vocab]
    dot = sum(a * b for a, b in zip(v1, v2))
    n1 = math.sqrt(sum(a * a for a in v1))
    n2 = math.sqrt(sum(b * b for b in v2))
    return 0.0 if n1 == 0 or n2 == 0 else dot / (n1 * n2)


def vector_cosine(v1, v2):
    dot = sum(a * b for a, b in zip(v1, v2))
    n1 = math.sqrt(sum(a * a for a in v1))
    n2 = math.sqrt(sum(b * b for b in v2))
    return 0.0 if n1 == 0 or n2 == 0 else dot / (n1 * n2)


# ---------------------------------------------------------------------------
# Stage 1 - query rewriting
# ---------------------------------------------------------------------------


def rewrite_query(raw_query, model):
    """Ask a model to repair the query. Few-shot, to pin down the output shape.

    Note there is no `except` here. An earlier version of this project silently
    returned the raw query on any error, which meant the headline feature of
    the lesson quietly did nothing on a machine without the right model, and
    nobody could tell.
    """
    prompt = (
        "Ban la bo tien xu ly cau hoi cho he thong tim kiem tai lieu noi bo. "
        "Viet lai cau hoi tho thanh mot cau hoi day du, dung chinh ta, co dau. "
        "Giu nguyen y dinh goc, khong them thong tin moi. "
        "Chi tra ve cau hoi da viet lai.\n\n"
        "Cau hoi tho: xin nghi phep nam bao lau truoc\n"
        "Viet lai: Xin nghỉ phép năm cần báo trước bao lâu?\n\n"
        "Cau hoi tho: ai duyet don nghi dai ngay\n"
        "Viet lai: Ai là người duyệt đơn nghỉ phép dài ngày?\n\n"
        f"Cau hoi tho: {raw_query}\n"
        "Viet lai:"
    )
    return chat(prompt, model).strip().strip('"')


def report_rewriting(raw, rewritten, embed_model):
    """How much does rewriting help - for keywords, and for embeddings?"""
    print("=== Stage 1: query rewriting ===")
    print(f"  raw       : {raw}")
    print(f"  rewritten : {rewritten}")
    target = next(d for d in DOCUMENTS if d["id"] == "doc2")

    keyword = (word_cosine(raw, target["text"]),
               word_cosine(rewritten, target["text"]))
    print(f"  match against doc2 (the correct document):")
    print(f"    bag-of-words : {keyword[0]:.4f} -> {keyword[1]:.4f}"
          f"   ({keyword[1] / max(keyword[0], 1e-9):.1f}x)")

    target_vector = embed(target["text"], embed_model)
    dense = (vector_cosine(embed(raw, embed_model), target_vector),
             vector_cosine(embed(rewritten, embed_model), target_vector))
    print(f"    embeddings   : {dense[0]:.4f} -> {dense[1]:.4f}"
          f"   ({dense[1] / dense[0]:.1f}x)")
    print("  Rewriting rescues keyword search; it only tunes semantic search.\n")


# ---------------------------------------------------------------------------
# Stage 2 - bi-encoder retrieval, then cross-encoder reranking
# ---------------------------------------------------------------------------


def bi_encoder_retrieve(query, documents, embed_model, k=4):
    """Encode query and documents separately, compare the frozen vectors."""
    query_vector = embed(query, embed_model)
    scored = [(vector_cosine(query_vector, embed(d["text"], embed_model)), d)
              for d in documents]
    scored.sort(key=lambda pair: pair[0], reverse=True)
    return scored[:k]


def cross_encoder_score(query, document, model):
    """Put query and document through ONE model together, and read the score.

    This is what makes it a cross-encoder: the two texts interact inside the
    network instead of being reduced to two independent vectors first.
    """
    prompt = (
        f'Cau hoi: "{query}"\n'
        f'Tai lieu: "{document}"\n\n'
        "Tai lieu nay tra loi truc tiep cau hoi o muc do nao? "
        "Chi tra ve DUY NHAT mot so nguyen tu 0 den 10, khong giai thich."
    )
    answer = chat(prompt, model)
    match = re.search(r"\d+", answer)
    return int(match.group()) if match else 0


def report_two_stage(query, embed_model, chat_model):
    print("=== Stage 2: bi-encoder retrieval, then cross-encoder reranking ===")
    start = time.time()
    candidates = bi_encoder_retrieve(query, DOCUMENTS, embed_model, k=4)
    bi_seconds = time.time() - start

    print(f"  bi-encoder top 4 ({bi_seconds:.2f}s for {len(DOCUMENTS)} documents)")
    for score, document in candidates:
        print(f"    {document['id']} {score:.3f}  {document['text'][:50]}")
    spread = candidates[0][0] - candidates[-1][0]
    lead = candidates[0][0] - candidates[1][0]
    print(f"    spread across all four: {spread:.3f};"
          f" lead over the runner-up: {lead:.3f}")

    start = time.time()
    reranked = [(cross_encoder_score(query, d["text"], chat_model), d)
                for _, d in candidates]
    cross_seconds = time.time() - start
    reranked.sort(key=lambda pair: pair[0], reverse=True)

    print(f"\n  cross-encoder rescoring ({cross_seconds:.2f}s for 4 documents)")
    for score, document in reranked:
        print(f"    {document['id']} {score:>2}/10  {document['text'][:50]}")
    print(f"    lead over the runner-up: {reranked[0][0] - reranked[1][0]} points")

    per_document = cross_seconds / 4
    whole_corpus = per_document * len(DOCUMENTS)
    print(f"\n  cost per document: {per_document:.2f}s. Running the"
          f" cross-encoder over")
    print(f"  all {len(DOCUMENTS)} documents would take {whole_corpus:.1f}s;"
          f" over a million it is")
    print(f"  {per_document * 1_000_000 / 3600:.0f} hours. That is why stage 1"
          f" exists.\n")
    return candidates, reranked


# ---------------------------------------------------------------------------
# Stage 3 - parent-child indexing
# ---------------------------------------------------------------------------

PARENT_DOCUMENT = """Quy trình nghỉ ốm của công ty gồm ba bước bắt buộc.
Khi bị ốm đột xuất, nhân viên cần nhắn Slack cho quản lý trực tiếp trước 9h00 sáng cùng ngày.
Nếu nghỉ quá hai ngày liên tiếp, nhân viên phải báo thêm cho bộ phận nhân sự.
Giấy xác nhận của bác sĩ phải được nộp khi nhân viên quay lại làm việc."""


def split_sentences(text):
    return [s.strip() for s in re.split(r"(?<=[.!?])\s+|\n", text) if s.strip()]


def report_parent_child(query, embed_model):
    """Search on small units, but hand the LLM the surrounding block."""
    print("=== Stage 3: parent-child indexing ===")
    children = split_sentences(PARENT_DOCUMENT)
    query_vector = embed(query, embed_model)
    scored = sorted(((vector_cosine(query_vector, embed(c, embed_model)), i)
                     for i, c in enumerate(children)), reverse=True)
    best_score, best_index = scored[0]
    print(f"  indexed {len(children)} child sentences from 1 parent block")
    print(f"  best child ({best_score:.3f}): {children[best_index]}")
    print(f"  child length : {len(children[best_index])} characters")
    print(f"  parent length: {len(PARENT_DOCUMENT)} characters")
    print("  The child is what gets matched; the parent is what the model reads.\n")
    return children[best_index], PARENT_DOCUMENT


def generate_answer(query, context, model):
    prompt = (
        "Chỉ dựa vào ngữ cảnh dưới đây để trả lời. "
        "Nếu không có thông tin, trả lời 'Không tìm thấy trong tài liệu'.\n\n"
        f"Ngữ cảnh:\n{context}\n\nCâu hỏi: {query}\nTrả lời:"
    )
    return chat(prompt, model)


def main():
    names = installed_models()
    if not names:
        print("Ollama is not reachable. Start it, then run this again.")
        return
    chat_model = pick(names, CHAT_PREFERRED)
    embed_model = pick(names, EMBED_PREFERRED)
    if not chat_model or not embed_model:
        print("This lesson needs both a chat model and an embedding model.")
        print("  ollama pull qwen2.5:7b")
        print("  ollama pull bge-m3")
        return
    print(f"chat model: {chat_model}\nembedding model: {embed_model}\n")

    rewritten = rewrite_query(RAW_QUERY, chat_model)
    report_rewriting(RAW_QUERY, rewritten, embed_model)

    question = "nghỉ ốm thì phải báo trước mấy giờ?"
    candidates, reranked = report_two_stage(question, embed_model, chat_model)

    child, parent = report_parent_child(question, embed_model)
    # Same question both times: the only variable is how much context we pass.
    broad = "Quy trình nghỉ ốm gồm những bước nào?"
    print("=== Same question, child context versus parent context ===")
    print(f"  question: {broad}\n")
    for label, context in (("child only", child), ("parent block", parent)):
        print(f"  from the {label} ({len(context)} chars):")
        for line in generate_answer(broad, context, chat_model).splitlines():
            print(f"    {line}" if line.strip() else "")
        print()


if __name__ == "__main__":
    main()
💡 Vì sao hàm rewrite_query không có khối except
Phiên bản đầu tiên của dự án này bọc lời gọi Ollama trong except Exception: return raw_query — nghĩa là khi có bất kỳ trục trặc nào (chưa cài mô hình, gõ sai tên, Ollama chưa bật), hàm âm thầm trả về câu hỏi gốc. Chương trình vẫn chạy hết, vẫn in ra kết quả trông hợp lý, và tính năng chủ đạo của cả bài học thì không hề hoạt động — không ai biết. Ở bản này lỗi được ném ra và chương trình dừng, kèm thông báo nói rõ thiếu mô hình nào.

Cách chạy dự án này trên máy bạn

  1. Bật Ollama, tải cả hai mô hình: ollama pull qwen2.5:7bollama pull bge-m3. Chạy python3 advanced_rag.py.
  2. Các con số trên máy bạn sẽ khác — điểm Cross-Encoder do một LLM chấm, và mô hình khác nhau chấm khác nhau (một mô hình chuyên code chấm khắt khe hơn hẳn mô hình instruct). Cái phải giống là hình dạng: Bi-Encoder cho một dải điểm sát nhau, Cross-Encoder tách bạch hẳn ra, và chi phí mỗi tài liệu của Cross-Encoder lớn hơn nhiều bậc.
  3. Rồi thử phá nó theo ba cách:
    • Bỏ hai ví dụ Few-shot trong rewrite_query, chỉ để lại phần mô tả nhiệm vụ. Câu viết lại sẽ dài dòng hơn, đôi khi kèm lời giải thích — và bạn thấy vì sao Few-shot ở Bài 11 lại là công cụ ghìm định dạng rẻ nhất.
    • Đổi CHAT_PREFERRED để dùng một mô hình chuyên code làm Cross-Encoder. Trên máy viết bài, cùng câu hỏi và cùng tài liệu, mô hình instruct chấm tài liệu đúng 7/10 còn mô hình code chấm chỉ 3/10 — thứ tự vẫn đúng, nhưng thang điểm dịch hẳn. Đây là lý do điểm Cross-Encoder chỉ nên dùng để xếp hạng, đừng bao giờ đem so với một ngưỡng tuyệt đối.
    • Trong bi_encoder_retrieve, hạ k=4 xuống k=1. Reranker mất hết tác dụng vì không còn gì để xếp lại — và bạn thấy rõ tầng một phải trả về đủ rộng thì tầng hai mới có việc để làm.

Tóm tắt bài học & Cầu nối kiến thức

🔑 Bài học đạt được:
  • Đạt được: Dựng Query Rewriting bằng Few-shot Prompting, và đo được mức lợi phụ thuộc bộ truy xuất: gấp $3.5$ lần với tìm kiếm từ khóa nhưng chỉ $1.8$ lần với vector nhúng.
  • Đạt được: Cài đặt Cross-Encoder thật và thấy khác biệt cốt lõi: Bi-Encoder xếp cả bốn ứng viên trong dải $0.062$, còn Cross-Encoder tách thành $7/2/0/0$.
  • Đạt được: Đo chi phí $0.23$ giây mỗi tài liệu của Cross-Encoder — tức khoảng 64 giờ cho một câu hỏi trên kho một triệu tài liệu — con số biện minh cho kiến trúc hai tầng.
  • Đạt được: Triển khai Parent-Child Indexing, và thấy cùng một câu hỏi cho ra câu trả lời thiếu 2/3 nội dung khi chỉ nạp Child thay vì Parent.

Cầu nối bài tiếp theo: Đến đây hệ thống đã trả lời tốt một câu hỏi bằng một lượt truy xuất. Nhưng nhiều câu hỏi thật cần nhiều bước: tra cái này, dựa vào kết quả để tra tiếp cái kia, rồi mới tổng hợp. Bài 17 chuyển từ đường ống cố định sang AI Agent tự quyết định bước tiếp theo, với vòng lặp ReAct.

Tải file code thực hành minh họa bài học

File Python advanced_rag.py — mã nguồn xây dựng quy trình nâng cao tích hợp viết lại câu hỏi, truy xuất vector thô và sắp xếp lại kết quả bằng thuật toán lai tương tác chéo (chạy python advanced_rag.py):

Tải về advanced_rag.py

📖 Tài liệu tham khảo

Bài viết liên quan trong series

Bài 15: Chiến thuật Chunking tối ưu & Vector Databases chuyên sâu Bài 17: AI Agents & Vòng lặp ReAct Quay lại Lộ trình Kỹ Sư AI Thực Chiến

Bình luận