- RAG giải bài toán tài liệu vượt context window: cắt chunk → embedding → tìm chunk liên quan → nhét vào prompt.
- 4 chiến lược text chunking; semantic search bằng cosine similarity trên embeddings; triển khai qua class VectorIndex 5 bước.
- BM25 vá điểm yếu exact-match của semantic search; hybrid search gộp hai đường bằng Reciprocal Rank Fusion (RRF).
TL;DR — RAG từ số 0 theo khoá: chunking → embeddings → semantic search → VectorIndex, rồi nâng cấp bằng BM25 + hybrid search (RRF) để vá điểm yếu exact-match.
Phần 4/7 của ghi chú khoá Building with the Claude API — đề thi thử 589 câu (chấm điểm + giải thích) nằm ở tab "Đề thi thử" trên trang tổng quan.
RAG — làm việc với tài liệu quá lớn (mở màn)
TL;DR mini. Tài liệu quá to (vd báo cáo tài chính 800 trang) không nhét trọn vào một prompt được. Option 1 = nhồi tất cả vào prompt (đơn giản nhưng đụng trần context + đắt + chậm + Claude loãng). Option 2 = RAG — pha preprocessing cắt tài liệu thành chunks, lúc user hỏi chỉ tìm chunk liên quan nhất rồi bỏ mỗi mẩu đó vào prompt. RAG đổi simplicity lấy scalability & efficiency — chỉ đáng làm khi tài liệu rất lớn / nhiều tài liệu / cần tối ưu cost·tốc độ.
Bài toán. Muốn hỏi Claude một câu cụ thể về một tài liệu khổng lồ ("What risk factors does this company have?" trên một financial document 800 trang). Phải đưa phần thông tin liên quan tới Claude, nhưng có giới hạn lượng text nhét vào một prompt. Đây chính là mấy pain point (context window + chi phí input token ~O(n²)) đã lộ ra ở bài chatbot quay lại.
Option 1 — Include everything in the prompt (prompt stuffing). Rút hết text ra, nhồi cả cục vào prompt kèm câu hỏi:
Answer the user's question about the financial document.
<user_question>{user_question}</user_question>
<financial_document>{financial_document}</financial_document>
4 giới hạn nghiêm trọng:
| # | Giới hạn |
|---|---|
| 1 | Hard limit — tài liệu có thể dài quá trần prompt/context window, nhét không vừa. |
| 2 | Claude kém hiệu quả hơn với prompt rất dài (loãng, khó tập trung đúng chỗ). |
| 3 | Prompt lớn hơn → cost more (nhiều input token). |
| 4 | Prompt lớn hơn → take longer (chậm hơn). |
Option 2 — RAG (Retrieval Augmented Generation). Cách thông minh, chia 2 pha:
- Preprocessing (làm TRƯỚC, offline): cắt tài liệu thành nhiều chunks (mẩu) — vd tách theo section:
Strategy Outlook·Balance Sheet·Risk Factors·Auditor's Report·Key Performance Indicators·Market Data. - Runtime (lúc user hỏi): tìm chunk(s) most relevant tới câu hỏi rồi chỉ include những chunk đó vào prompt.
Ví dụ: user hỏi "What risks does this company face?" → search qua các chunk → thấy chunk "Risk Factors" khớp → chỉ nhét mỗi mẩu đó vào prompt.
Ẩn dụ: RAG = thủ thư. Hỏi một câu, thủ thư không bê cả thư viện ra, mà đi thẳng đúng kệ, rút đúng cuốn, mở đúng chương đưa mình.
Benefits vs Challenges:
| ✅ Benefits | ⚠️ Challenges |
|---|---|
| Claude tập trung vào only the most relevant content | Cần bước preprocessing (chunk documents) |
| Scales lên tài liệu rất lớn | Cần một search mechanism để tìm chunk "relevant" |
| Chạy được với multiple documents | Chunk lấy về might not contain all the context Claude cần |
| Prompt nhỏ hơn → cost less & run faster | Nhiều cách chunk — cắt sao cho tốt là bài toán riêng |
Cách chunk: có thể cắt equal-sized portions (đều nhau) hoặc theo document structure (headers/sections). Mỗi cách có trade-off, phải tự đánh giá theo use case.
When to use RAG. RAG trades simplicity for scalability and efficiency — tốn công dựng pipeline (chunking + search) hơn Option 1 nhiều. Chỉ đáng khi tài liệu rất lớn, nhiều tài liệu, hoặc cần tối ưu cost/performance. Tài liệu nhỏ thì Option 1 (prompt stuffing) nhanh gọn hơn — đừng dựng RAG cho vui.
Góc Delivery Manager. RAG là quyết định build-vs-buy-complexity kinh điển, không phải "mặc định phải làm". (1) Đừng RAG sớm: nếu tài liệu vừa context window, prompt stuffing (Option 1) đơn giản → ít điểm hỏng → ship nhanh; RAG thêm cả một pipeline (chunk store, search/embedding, resync khi tài liệu đổi) = nợ vận hành. Chỉ trả nợ đó khi quy mô buộc phải. (2) Rủi ro số 1 là "chunk thiếu context" — câu trả lời nghe hợp lý nhưng sai vì phần bổ trợ nằm ở chunk khác không được lấy về; đây là loại bug khó thấy, phải test bằng eval pipeline (đúng bộ khung eval đã học ở phần trước) chứ không "chạy thử vài câu thấy ổn". (3) Chiến lược chunking = đòn bẩy chất lượng lớn nhất và nên chốt sớm theo cấu trúc dữ liệu thật (theo section/heading thường thắng cắt đều) vì đổi cách chunk = phải reprocess toàn bộ. Mental model: RAG không phải "cho xịn" mà là cái giá phải trả để tài liệu lớn vẫn rẻ và không vỡ context — thẳng hàng với thế giới retrieval/citation minh bạch mình vẫn làm.
Text chunking — 4 chiến lược cắt tài liệu
TL;DR mini. Chunking = pha preprocessing của RAG, và là bước dễ hỏng nhất: cắt dở → nhét nhầm context → Claude trả lời sai hoàn toàn (ví dụ "bug" nghĩa con bọ vs lỗi phần mềm). 4 chiến lược từ đơn giản → tinh vi: Size-based (cắt đều theo ký tự, thêm overlap) · Structure-based (cắt theo headers/sections, hợp Markdown) · Semantic-based (gom câu cùng nghĩa bằng NLP, đắt nhất) · Sentence-based (gom N câu + overlap, middle ground). Không có chiến lược "tốt nhất" duy nhất — tuỳ tài liệu + trade-off complexity ↔ quality. Size-based + overlap = go-to ở production (đơn giản, tin cậy, chạy mọi loại tài liệu kể cả code).
Vì sao chunking sống còn. Cách cắt tài liệu tác động trực tiếp tới chất lượng cả hệ thống RAG. Chunk dở → irrelevant context lọt vào prompt → AI trả lời sai. Ví dụ: tài liệu có mảng medical research + software engineering; user hỏi "How many bugs did engineers fix this year?" nhưng nhận về thông tin medical research — chỉ vì mảng y khoa tình cờ chứa từ "bug" theo nghĩa khác (con bọ/vi khuẩn). Cùng một từ, khác nghĩa → chunk sai → đáp án sai bét.
1. Size-based chunking — chia text thành các strings dài bằng nhau (doc 325 ký tự → 3 chunk ~108 ký tự).
| ✅ | ❌ |
|---|---|
| Dễ implement nhất | Words get cut off giữa câu |
| Chạy với mọi loại tài liệu (kể cả code) | Chunk mất context xung quanh |
| Section headers bị tách khỏi nội dung |
Fix = thêm overlap: mỗi chunk lấn sang vài ký tự của chunk kế bên → giữ câu trọn vẹn + context nối tiếp.
def chunk_by_char(text, chunk_size=150, chunk_overlap=20):
chunks = []
start_idx = 0
while start_idx < len(text):
end_idx = min(start_idx + chunk_size, len(text)) # không vượt quá cuối text
chunk_text = text[start_idx:end_idx]
chunks.append(chunk_text)
# nhảy tới = cuối chunk LÙI LẠI overlap → chunk sau lấn vào chunk trước
start_idx = end_idx - chunk_overlap if end_idx < len(text) else len(text)
return chunks
Mấu chốt là
start_idx = end_idx - chunk_overlap: thay vì nhảy thẳng tớiend_idx, nó lùi lạichunk_overlapký tự → tạo vùng chồng lấn. Nhánhelse len(text)để khi chạm cuối thì dừng hẳn, tránh lặp vô hạn.
2. Structure-based chunking — cắt theo cấu trúc tự nhiên (headers · paragraphs · sections). Tuyệt cho tài liệu format tốt như Markdown.
def chunk_by_section(document_text):
pattern = r"\n## "
return re.split(pattern, document_text) # cắt tại mỗi heading H2
- ✅ Chunk sạch & ý nghĩa nhất — mỗi chunk là một section trọn vẹn.
- ❌ Chỉ chạy khi đảm bảo được document structure. Nhiều tài liệu thật là plain text / PDF không có markers rõ ràng.
3. Semantic-based chunking — tinh vi nhất. Cắt thành sentences, dùng NLP đo độ liên quan giữa các câu liên tiếp, gom nhóm câu cùng chủ đề thành chunk.
- ✅ Chunk most relevant (đúng ý nghĩa nhất).
- ❌ Computationally expensive · phức tạp nhất để implement (phải hiểu nghĩa từng câu).
4. Sentence-based chunking — middle ground thực dụng. Cắt thành câu bằng regex, gom N câu một chunk kèm overlap tính theo số câu.
def chunk_by_sentence(text, max_sentences_per_chunk=5, overlap_sentences=1):
sentences = re.split(r"(?<=[.!?])\s+", text) # cắt sau . ! ? + khoảng trắng
chunks = []
start_idx = 0
while start_idx < len(sentences):
end_idx = min(start_idx + max_sentences_per_chunk, len(sentences))
current_chunk = sentences[start_idx:end_idx]
chunks.append(" ".join(current_chunk))
start_idx += max_sentences_per_chunk - overlap_sentences # tiến bước, chừa overlap câu
if start_idx < 0:
start_idx = 0
return chunks
Regex
(?<=[.!?])\s+= lookbehind: cắt tại khoảng trắng đứng ngay sau dấu./!/?, nhờ đó giữ dấu câu dính vào câu thay vì nuốt mất. Overlap ở đây đếm theo câu, không phải ký tự.
Choosing your strategy — tuỳ use case + document guarantees:
| Chiến lược | Hợp khi | Ghi nhớ |
|---|---|---|
| Structure-based | Kiểm soát được format (báo cáo nội bộ công ty) | Chunk sạch nhất, cần đảm bảo cấu trúc |
| Sentence-based | Đa số text documents | Middle ground tốt |
| Size-based | Fallback tin cậy nhất — mọi content, kể cả code | Đơn giản, không vỡ pipeline |
⭐ Size-based + overlap thường là go-to ở production: simple · reliable · chạy mọi document type. Không hoàn hảo nhưng luôn ra chunk hợp lý, không làm vỡ pipeline. Câu chốt: không có chiến lược chunking "tốt nhất" duy nhất — đúng hay không tuỳ documents · use cases · trade-off implementation complexity ↔ chunk quality.
Góc Delivery Manager. Chunking là chỗ "rẻ để làm ẩu, đắt để sửa". (1) Bắt đầu bằng size-based + overlap đúng như production go-to: nó là baseline không bao giờ vỡ để có pipeline chạy end-to-end trước, rồi mới đo xem chất lượng chunk có phải là nút thắt không — đừng nhảy thẳng vào semantic (đắt compute + phức tạp) khi chưa có bằng chứng cần. (2) Chiến lược chunking phải khớp với "document guarantees" mình thật sự có: structure-based thắng đậm chỉ khi mình kiểm soát format (báo cáo nội bộ Markdown) — với tài liệu khách gửi (PDF/plain text lộn xộn) thì cược vào cấu trúc là tự bẫy mình. (3) overlap là núm đánh đổi cost↔context: overlap lớn giữ ngữ cảnh tốt hơn nhưng chunk trùng lặp nhiều = tốn token/lưu trữ; chọn theo use case. Cùng một triết lý eval như phần trước: cách duy nhất biết chunking đủ tốt là đo bằng eval trên câu hỏi thật, không phải nhìn chunk thấy "gọn gàng" là yên tâm.
Semantic search & text embeddings — tìm chunk liên quan
TL;DR mini. Sau chunking, bước kế của RAG = tìm chunk liên quan nhất tới câu hỏi = một search problem. Cách phổ biến = semantic search: khác keyword search (khớp từ y hệt), nó dùng text embeddings để hiểu nghĩa & ngữ cảnh của cả câu hỏi lẫn từng chunk. Embedding = biểu diễn nghĩa của text bằng một list số dài, mỗi số trong khoảng −1 → +1, mỗi số là "score" cho một đặc tính — nhưng ta KHÔNG biết chính xác mỗi số nghĩa là gì (model tự học lúc training, người không giải nghĩa trực tiếp được). Anthropic không cung cấp embedding → khoá khuyên VoyageAI (tài khoản riêng, key miễn phí, biến env
VOYAGE_API_KEY).
Đang ở đâu. RAG có 2 pha: preprocessing (chunking, đã học) + runtime (tìm chunk liên quan rồi đưa vào prompt). Bài này là bước tìm chunk — bản chất là search problem: lục qua tất cả chunk, chọn mẩu dính tới câu hỏi.
Semantic search vs keyword search:
| Keyword search | Semantic search | |
|---|---|---|
| Tìm theo | exact word match (khớp từ y hệt) | meaning & context (nghĩa & ngữ cảnh) |
| Ví dụ | hỏi "bug" chỉ ra chunk chứa chữ "bug" | hỏi "lỗi phần mềm" vẫn ra chunk nói error/crash/defect |
| Công cụ | so chuỗi | text embeddings |
→ Chính là thứ vá "vấn đề con bug/biomarker": keyword đếm chữ nên lẫn lộn; semantic hiểu nghĩa nên phân biệt được.
Text embedding. = biểu diễn nghĩa của text bằng số (đưa chữ về dạng máy tính toán được). Quy trình: đưa text vào embedding model → model nhả ra một list số rất dài (chính là embedding) → mỗi số trong khoảng −1 → +1 → mỗi số là "điểm" cho một đặc tính của text.
Ẩn dụ: embedding = toạ độ GPS của nghĩa. Mỗi text được gán một "vị trí" trong không-gian-nghĩa; text nói chuyện giống nhau nằm gần nhau. Bước so sánh (bài sau) = đo khoảng cách giữa các toạ độ.
⭐ Điểm dễ nhầm nhất — không biết mỗi số nghĩa là gì. Mỗi số là "score" cho một đặc tính, nhưng ta KHÔNG biết chính xác đặc tính đó là gì. Kiểu "số này đo text vui cỡ nào" hay "số kia đo text nói về oceans nhiều không" chỉ là ví dụ khái niệm cho dễ hình dung — ý nghĩa thật của từng dimension do model tự học lúc training, con người không interpret trực tiếp được.
VoyageAI. Anthropic hiện không cung cấp embedding generation → khoá khuyên VoyageAI: đăng ký tài khoản riêng · lấy API key (free để bắt đầu) · thêm vào env. Trong .env: VOYAGE_API_KEY="your_key_here".
%pip install voyageai # cài thư viện
from dotenv import load_dotenv
import voyageai
load_dotenv()
client = voyageai.Client() # tự đọc VOYAGE_API_KEY từ env (giống Anthropic())
def generate_embedding(text, model="voyage-3-large", input_type="query"):
result = client.embed([text], model=model, input_type=input_type)
return result.embeddings[0] # embed nhận 1 LIST text -> lấy phần tử [0]
2 chi tiết đáng nhớ: client.embed([text], ...) nhận một list (embed được nhiều text cùng lúc) → phải lấy .embeddings[0]; input_type có "query" (câu hỏi) vs "document" (chunk tài liệu) — đánh dấu đúng loại giúp tối ưu so khớp query↔document. Chạy hàm trên một chunk → nhận về list floats. Sinh embedding thì nhanh & dễ; cái khó là dùng chúng. Bước sau: so sánh embeddings (đo độ giống nhau query↔chunk) = lõi của semantic search.
Góc Delivery Manager. (1) Embedding thêm một nhà cung cấp thứ ba (VoyageAI) vào kiến trúc — nghĩa là thêm một API key để quản lý, một điểm phụ thuộc/điểm hỏng, một dòng chi phí, và một bề mặt dữ liệu nữa (chunk gửi sang VoyageAI để embed): với dữ liệu nhạy cảm phải soát điều khoản như đã soát web search. (2) "Không interpret được từng dimension" là một sự thật vận hành, không phải chi tiết học thuật: không debug semantic search bằng cách "đọc" vector — chỉ đánh giá được qua kết quả truy hồi (chunk lấy về có đúng không), nên lại quay về eval trên câu hỏi thật. (3)
input_typequery vs document là bẫy tinh vi: đánh dấu sai loại làm giảm chất lượng match một cách âm thầm (không lỗi, chỉ "kém dần") — đúng kiểu bug khó thấy cần checklist review. Embedding = đổi chữ (máy khó so) lấy số (máy so bằng toán) — nền cho mọi thứ semantic phía sau.
RAG pipeline hoàn chỉnh — 6 bước & cosine similarity
TL;DR mini. Ráp cả cụm lại: RAG chạy end-to-end qua 6 bước — (1) Chunk → (2) Embed → (3) Store vào vector database → ⏸ chờ user → (4) Embed query (cùng model) → (5) Find similar (vector DB trả chunk gần nhất) → (6) Final prompt (ghép query + chunk trúng gửi Claude). Bước 1–3 = preprocessing làm sẵn; 4–6 = runtime. Cách đo "gần": cosine similarity (cosine của GÓC giữa 2 vector; −1 → 1, gần 1 = giống, 0 = vuông góc/không liên quan, gần −1 = ngược). Cosine distance = 1 − cosine similarity (gần 0 = giống). Vector thường được normalize về magnitude 1.0 trước khi so.
Ví dụ gài bẫy chéo nghĩa (đúng "vấn đề con bug"): chunk Medical chứa chữ "bug" (nghĩa vi khuẩn), chunk Software chứa "infection vectors" (nghe y khoa nhưng là bảo mật). Keyword search lẫn lộn; semantic search phân biệt được nhờ nghĩa tổng thể.
6 bước:
| Bước | Việc | Pha |
|---|---|---|
| 1. Chunk | Cắt tài liệu thành mẩu | preprocessing |
| 2. Embed | Mỗi chunk → vector số | preprocessing |
| 3. Store | Nhét vector vào vector database | preprocessing |
| ⏸ | Dừng — chờ user submit query | — |
| 4. Process query | Câu hỏi user → vector (CÙNG embedding model) | runtime |
| 5. Find similar | Gửi query embedding cho vector DB → trả chunk gần nhất | runtime |
| 6. Final prompt | Ghép user question + chunk trúng → gửi Claude | runtime |
⭐ Ranh giới preprocessing (1–3, làm sẵn ahead of time) vs runtime (4–6, lúc user hỏi) hay ra thi.
Ví dụ số (imaginary model 2 chiều = [mức y-khoa, mức phần-mềm]): Medical [0.97, 0.34] (đậm y khoa, dính chút phần mềm vì "bug") · Software [0.30, 0.97] (đậm phần mềm, dính chút y khoa vì "infection vectors") · Query "what did the software engineering dept do?" [0.1, 0.89] (thấp y-khoa, cao phần-mềm).
Normalization. API embedding thường scale vector về magnitude = 1.0 (tự động, không cần lo toán). Sau chuẩn hoá: Medical [0.944, 0.331] · Software [0.295, 0.955] · Query [0.112, 0.993]. Vẽ lên unit circle thì mỗi chunk là một điểm trên vòng tròn — cosine chỉ quan tâm hướng, nên chuẩn hoá độ dài về 1 để so góc cho sạch.
Vector database = database chuyên dụng để store · compare · search các list số dài (embedding). Là nơi cất kết quả bước 3.
Cosine similarity = đo cosine của GÓC giữa 2 vector:
| Giá trị | Ý nghĩa |
|---|---|
| Gần +1 | Rất giống (cùng hướng) |
| 0 | Vuông góc — no relationship |
| Gần −1 | Rất khác (ngược hướng) |
Trong ví dụ: Query ↔ Software = 0.983 (rất cao → bước 5 chọn cái này) · Query ↔ Medical = 0.398 (thấp hơn hẳn). → Vector DB trả Software section.
Cosine distance = 1 − cosine similarity — gần 0 = rất giống, càng lớn = càng khác. Chỉ là đổi cách đọc cho tiện (nhiều vector DB doc dùng distance). ⚠️ Đừng lẫn: similarity CAO = giống nhưng distance THẤP = giống.
Bước 6 — final prompt: ghép user question + chunk trúng rồi gửi Claude:
Answer the user's question about the financial document.
<user_question>How many bugs did engineers fix this year?</user_question>
<report>## Section 2: Software Engineering
This division dedicated significant effort to studying various infection vectors in our distributed systems</report>
Claude giờ có đúng ngữ cảnh → trả lời chính xác. Đó là RAG trọn vẹn: chunk → embed → store → (query) embed → cosine search → prompt.
Góc Delivery Manager. (1) Cả pipeline chỉ mạnh bằng mắt xích yếu nhất — chunking dở (bước 1) hay chọn nhầm model giữa index và query (bước 2 vs 4) làm hỏng bước 5 dù cosine đúng tuyệt đối; nên nghiệm thu RAG phải test xuyên suốt end-to-end trên câu hỏi thật, không nghiệm thu từng mảnh rời. (2) Vector database là một hạ tầng có nhà nữa (chi phí, vận hành, backup, đồng bộ khi tài liệu đổi) — với tập nhỏ có khi chỉ cần so cosine trong bộ nhớ; đừng kéo cả vector DB vào khi chưa cần scale. (3) cosine similarity vs distance là bẫy đọc số: dashboard/log trộn hai thang (cao=giống vs thấp=giống) dễ khiến cảnh báo hiểu ngược — chốt một quy ước và ghi rõ. Bức tranh lớn: RAG = cơ chế truy hồi có kiểm soát để Claude trả lời có căn cứ thay vì bịa — thẳng hàng với thế giới AEO/GEO/citation mình theo đuổi.
RAG implementation — class VectorIndex & 5 bước code
TL;DR mini. Code hoá pipeline bằng một vector store tự viết (
VectorIndex, in-memory).generate_embeddingđược nâng cấp nhận cả string lẫn list (batch). 5 bước: (1)chunk_by_section→ (2)generate_embedding(chunks)batch → (3)VectorIndex()+add_vector(embedding, {"content": chunk})→ (4) embed câu hỏi → (5)store.search(user_embedding, 2). ⭐ Lưu TEXT GỐC kèm embedding ({"content": chunk}) vì search trả về số thì vô dụng — cần đoạn text để ghép prompt. Kết quả: Section 2 (distance 0.71) gần nhất, Methodology (0.72) nhì — distance thấp = giống hơn. Bài chốt: basic works nhưng đôi khi sai → cải thiện ở các bài sau.
generate_embedding nâng cấp — nhận string HOẶC list:
def generate_embedding(chunks, model="voyage-3-large", input_type="query"):
is_list = isinstance(chunks, list)
input = chunks if is_list else [chunks] # 1 string -> bọc thành list
result = client.embed(input, model=model, input_type=input_type)
return result.embeddings if is_list else result.embeddings[0]
Đưa list → trả list embedding (batch, ít request → né rate limit); đưa 1 string → trả 1 embedding.
VectorIndex — "vector database" thu nhỏ. Giữ 2 list song song: self.vectors (các embedding) + self.documents (metadata, trong đó có {"content": chunk} = text gốc).
| Method | Việc |
|---|---|
add_vector(vector, document) |
Thêm 1 cặp (embedding + document dict). Kiểm tra cùng số chiều — lệch dimension thì raise (document PHẢI có key "content"). |
add_document(document) |
Tự embed document["content"] (cần embedding_fn lúc init) rồi gọi add_vector. |
search(query, k) |
Tính distance từ query tới MỌI vector đã lưu → sort tăng dần → trả k cái gần nhất dạng [(doc, distance)]. Query là string thì tự embed (nếu có embedding_fn), hoặc nhận thẳng list số. |
_cosine_distance |
1 − cosine_similarity; clamp cosine về [-1, 1] tránh lỗi làm tròn; mag = 0 xử lý riêng (trả 0.0 nếu cả hai rỗng, 1.0 nếu một rỗng). |
_euclidean_distance |
Tuỳ chọn: sqrt(Σ(p−q)²) — khoảng cách thẳng (init chọn distance_metric="cosine" hoặc "euclidean"). |
⭐ Vì sao lưu TEXT GỐC cùng embedding. Search xong vector DB trả về số — mà số thì vô dụng để nhét vào prompt. Nên lúc lưu phải kèm {"content": chunk} → search xong lấy được đúng đoạn text ghép vào prompt gửi Claude. Không lưu text = tìm được vector nhưng không biết nó là đoạn nào.
5 bước code:
chunks = chunk_by_section(text) # 1. Chunk
embeddings = generate_embedding(chunks) # 2. Embed (batch)
store = VectorIndex() # 3. Store
for embedding, chunk in zip(embeddings, chunks):
store.add_vector(embedding, {"content": chunk})
user_embedding = generate_embedding( # 4. Embed query
"What did the software engineering dept do last year?")
results = store.search(user_embedding, 2) # 5. Search top-2
for doc, distance in results:
print(distance, "\n", doc["content"][0:200], "\n")
Đọc kết quả: Section 2 Software Engineering distance 0.71 (gần nhất) · Methodology 0.72 (nhì). Distance THẤP = giống hơn (cosine distance, không phải similarity). Section 2 thắng → đúng ý user.
⭐ Teaser: "works well for basic cases, but there are scenarios where it doesn't perform as expected." Methodology đứng nhì sát nút (0.72) dù chỉ nói về "cách làm báo cáo", không thật sự về software → retrieval chưa hoàn hảo; bài sau cải thiện. Key takeaway: RAG = biến text → số (embedding) → lưu số hiệu quả → dùng toán (similarity) tìm nội dung liên quan.
Góc Delivery Manager. (1) Metadata đi kèm vector là quyết định thiết kế, không phải chi tiết vặt — thực tế cần lưu thêm
source,section,chunk_id,urlđể còn trích dẫn nguồn (thẳng hàng AEO/citation mình theo) và debug được "câu trả lời này lấy từ đâu". (2)searchở đây quét O(n) toàn bộ vector — ổn cho demo, nhưng ở scale lớn phải dùng ANN index (vector DB thật) nếu không tra cứu chậm dần; đây đúng là ranh giới "khi nào cần vector DB" đã nói ở bài pipeline. (3) Methodology lọt top-2 là bằng chứng sống: "chạy ra kết quả" ≠ "kết quả đúng" — retrieval quality phải đo bằng eval trên câu hỏi thật (dùng lại bộ khung eval đã học), vì lỗi ở đây âm thầm (không exception, chỉ trả nhầm chunk). ClassVectorIndextách distance metric (cosine/euclidean) ra tham số = đúng tinh thần đổi chiến lược không đập lại code gọi.
BM25 & hybrid search — vá điểm yếu của semantic search
TL;DR mini. Semantic search giỏi nghĩa nhưng dở khớp chính xác (tìm
INC-2023-Q4-011có thể trả nhầm section chỉ liên quan khái niệm). Vá bằng hybrid search: chạy song song semantic + lexical search (BM25) rồi merge. BM25 (Best Match 25) = thuật toán lexical, 4 bước: tokenize → count term frequency → weight by importance (IDF: term càng hiếm điểm càng cao) → find best matches.BM25Index(k1=1.5,b=0.75, tokenizer mặc định lowercase +\W+);searchtrả(doc, normalized_score)— chuẩn hoáexp(−0.1×raw)+ sort tăng → dạng giống distance (thấp = tốt) để merge với cosine. BM25 mạnh cho IDs, technical terms, cụm chính xác. Bài sau: merge thành hybrid. ⚡ BM25 thuần Python, KHÔNG gọi API.
Vấn đề — semantic một mình chưa đủ. Semantic search so nghĩa, không so chữ: tìm mã INC-2023-Q4-011 có thể trả về Cybersecurity (đúng, chứa mã) nhưng cũng lôi Financial Analysis (chỉ liên quan khái niệm, không chứa mã). Đây là điểm yếu làm Methodology lọt top-2 ở bài trước.
Hybrid search = chạy song song 2 kiểu tìm rồi merge:
| Tìm bằng | Giỏi | |
|---|---|---|
| Semantic search | embeddings | nghĩa, ngữ cảnh (conceptual) |
| Lexical search (BM25) | khớp chữ cổ điển | exact term, ID, thuật ngữ |
| Merged | cả hai | chính xác hơn hẳn |
BM25 (Best Match 25) — 4 bước:
- Tokenize query — cắt câu hỏi thành term: "a INC-2023-Q4-011" →
["a", "inc", "2023", "q4", "011"](mặc định lowercase + split\W+→ mã bị tách thành mảnh nhưng vẫn hiếm). - Count term frequency — đếm mỗi term xuất hiện bao nhiêu trong toàn bộ documents ("a" nhiều · mã rất ít).
- Weight terms by importance (IDF) — term càng hiếm → điểm càng CAO; từ phổ biến ("a") điểm thấp (vô giá trị tìm kiếm).
- Find best matches — trả document chứa nhiều instance của term điểm cao.
Class BM25Index — chi tiết đáng nhớ:
- Tham số
k1=1.5(term frequency saturation) ·b=0.75(length normalization) · tokenizer mặc định:text.lower()+re.split(r"\W+")bỏ token rỗng. - IDF =
log(((N − freq + 0.5) / (freq + 0.5)) + 1)—freq(số document chứa term) thấp → IDF cao. - BM25 score mỗi doc = Σ theo query token:
idf * tf*(k1+1) / (tf + k1*(1 − b + b*doc_len/avg_doc_len))(tf= số lần term trong doc đó). - ⭐
searchtrả(doc, normalized_score): tính raw score → sort giảm (raw cao = liên quan) → lấy top-k → normalizeexp(−0.1×raw)→ sort tăng → thành dạng giống distance (THẤP = tốt), để lát merge chung với cosine distance của semantic._index_builtlazy: gọi_build_index(tính avg_doc_len + IDF) khi search nếu vừa thêm doc.
Code dùng:
chunks = chunk_by_section(text) # 1. Chunk
store = BM25Index() # 2. BM25 store
for chunk in chunks:
store.add_document({"content": chunk})
results = store.search("What happened with INC-2023-Q4-011?", 3) # 3. Search
for doc, distance in results:
print(distance, "\n", doc["content"][:200], "\n----\n")
Kết quả giờ ưu tiên đúng Software Engineering + Cybersecurity — cả hai thật sự chứa mã.
Vì sao BM25 khá hơn cho exact match: cho rare term điểm cao · bỏ qua common words vô giá trị · dựa term frequency không dựa nghĩa · cực hợp technical terms · IDs · cụm chính xác. ⭐ Insight lõi: semantic vs lexical bù trừ cho nhau (complementary strengths) — semantic hiểu ngữ cảnh, lexical không bỏ sót exact match → bài kế merge thành hybrid.
Góc Delivery Manager. (1) "Không có một cách tìm đúng cho mọi truy vấn" — câu hỏi khái niệm ("công ty làm ăn thế nào?") hợp semantic; tra cứu định danh (mã lỗi, số hợp đồng, tên riêng) hợp lexical. Hybrid không phải "cho xịn" mà vì truy vấn thật trộn cả hai kiểu. (2) BM25 gần như miễn phí (thuần CPU, không API, không phụ thuộc nhà cung cấp) → thêm nó vào là nâng chất lượng gần như không thêm chi phí/độ trễ đáng kể — một nước đi hời hiếm có. (3) Chuẩn hoá điểm về cùng thang trước khi merge là cái bẫy kỹ thuật: BM25 raw score và cosine distance khác đơn vị hoàn toàn; class này khéo đưa BM25 về dạng "thấp = tốt" để ghép — nếu tự merge ẩu (cộng thẳng 2 thang khác nhau) sẽ ra thứ hạng vô nghĩa. Bức tranh: retrieval tốt = đo bằng eval trên tập truy vấn đại diện (có cả câu khái niệm lẫn câu tra ID), không chỉ nhìn một ví dụ đẹp.
Hybrid search — Retriever & Reciprocal Rank Fusion (RRF)
TL;DR mini. Ghép semantic (
VectorIndex) + lexical (BM25Index) thành hybrid. Cả hai cùng API (add_document,search) → bọc chung vào classRetriever(điều phối: forward query tới cả 2 index, gom kết quả, merge). Không nối thẳng 2 list được vì hệ điểm khác nhau → dùng Reciprocal Rank Fusion (RRF): chỉ dùng THỨ HẠNG chứ không dùng điểm.RRF_score(d) = Σ 1/(k + rank_i(d))(kthường 60, bài dùng 1; rank 1 = tốt nhất). Doc xếp cao ở nhiều index → RRF cao → lên đầu (RRF cao = tốt, ngược chiều distance). Extensible: mọi index theoSearchIndexProtocol đều gộp được, không sửa code cũ.
Multi-index architecture. VectorIndex và BM25Index có API gần như y hệt (add_document() + search()) → bọc chung vào Retriever. Retriever = coordinator: chuyển query tới cả hai index, gom kết quả, merge bằng RRF.
Vì sao không nối thẳng 2 list. Semantic trả cosine distance, BM25 trả normalized score — 2 hệ điểm khác nhau hoàn toàn; cộng thẳng ra thứ hạng vô nghĩa. RRF giải bằng mẹo thanh lịch: KHÔNG dùng điểm, chỉ dùng THỨ HẠNG (rank) → không cần hai thang cùng đơn vị.
Công thức RRF:
RRF_score(d) = Σ 1 / (k + rank_i(d))
rank_i(d)= thứ hạng của docdtrong bảng xếp hạng thứi(rank 1 = tốt nhất).k= hằng số (thường 60, bài dùng 1 cho số dễ nhìn); cộng đóng góp từ mọi index.
Ví dụ tính tay — Vector: S2(r1)·S7(r2)·S6(r3) · BM25: S6(r1)·S2(r2)·S7(r3), k=1:
| Doc | Tính | RRF |
|---|---|---|
| S2 | 1/(1+1) + 1/(1+2) | 0.833 |
| S6 | 1/(1+3) + 1/(1+1) | 0.75 |
| S7 | 1/(1+2) + 1/(1+3) | 0.583 |
→ S2 (0.833) > S6 (0.75) > S7 (0.583). S2 xếp cao ở CẢ HAI → trồi lên đầu. ⭐ RRF cao = tốt (sort giảm dần) — ngược chiều distance.
Implementation đáng nhớ:
add_document(doc)→ forward cho MỌI index;search→ gọisearchtừng index (over-fetchk*5cho RRF nhiều ứng viên) rồi tính RRF.- ⭐ Dùng
id(doc)nhận diện cùng một document xuất hiện ở nhiều index — chạy được vìRetriever.add_documenttruyền cùng một object dict cho cả hai index. k_rrfmặc định 60; sort giảm dần (RRF cao lên đầu); lọc bỏ score ≤ 0; trả top-k.
Kết quả hybrid. Câu "what happened with INC-2023-Q4-011?": vector-only từng cho Financial Analysis đứng nhì (sai). Hybrid giờ ra (1) Section 10 Cybersecurity · (2) Section 2 Software Engineering · (3) Section 5 Legal → vá điểm yếu của từng cái.
Extensibility. Mọi index tuân SearchIndex Protocol (cùng add_document + search). Thêm graph search / keyword / domain index? Chỉ cần implement đúng interface → Retriever tự động gộp vào RRF, không sửa code cũ (loose coupling, modular, testable).
Góc Delivery Manager. (1) RRF là bài học thiết kế đắt giá hơn cả công thức: gặp hai hệ điểm không cùng đơn vị, đừng ép chuẩn hoá cho khớp — hạ xuống mức "thứ hạng" là bất biến chung ai cũng có. Mẹo này tái dùng được khắp nơi (gộp nhiều nguồn xếp hạng: reviewer, model, KPI). (2)
Protocol+ API nhất quán = đòn bẩy kiến trúc: mỗi search method vẫn focused, testable riêng, mà vẫn cắm vào hệ chung không sửa code cũ — đúng tinh thần mở để mở rộng, đóng để sửa đổi; đây là thứ mình muốn ở một hệ retrieval sống lâu. (3)ktrong RRF là núm chỉnh:knhỏ (=1) khuếch đại khác biệt giữa top-ranks,klớn (=60) làm mượt — chọn theo eval, không theo cảm tính. Toàn cảnh: hybrid + RRF là cách rẻ và bền để nâng chất lượng retrieval — và chất lượng retrieval chính là trần chất lượng của cả RAG.
Phần tiếp theo: Claude API nâng cao — extended thinking, vision, PDF, citations, prompt caching
Nguồn: Building with the Claude API (Anthropic Academy) — Copyright Anthropic. Phần đề thi thử cho khoá này nằm ở tab "Đề thi thử" trên trang tổng quan khoá.