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.
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.
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.
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:
=== 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) và 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):
Đ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ờ?"
=== 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.
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ìm và vùng để đọc:
- 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.
- 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ỗ đó:
=== 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.
"""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()
rewrite_query không có khối except
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
-
Bật Ollama, tải cả hai mô hình:
ollama pull qwen2.5:7bvàollama pull bge-m3. Chạypython3 advanced_rag.py. - 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.
-
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=4xuốngk=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.
-
Bỏ hai ví dụ Few-shot trong
Tóm tắt bài học & Cầu nối kiến thứ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 liệu tham khảo
- Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks — Nghiên cứu đặt nền móng cho Bi-Encoder & Cross-Encoder (Reimers & Gurevych, 2019)
- Query Rewriting for Retrieval-Augmented Large Language Models — Khảo sát các kỹ thuật viết lại câu hỏi tối ưu hóa chất lượng RAG (Ma et al., 2023)
- Retrieve & Re-Rank Frame — Tài liệu chính thống hướng dẫn luồng tìm kiếm hai giai đoạn của Sentence-Transformers
Bình luận