Để xây dựng một hệ thống RAG hoạt động tốt ở quy mô lớn, việc chỉ cắt văn bản thô sơ theo số lượng ký tự là hoàn toàn chưa đủ. Chất lượng của dữ liệu tìm được phụ thuộc trực tiếp vào hai yếu tố cốt lõi: chiến thuật phân mảnh thông tin (Chunking) và khả năng lập chỉ mục (Indexing) của cơ sở dữ liệu Vector.
Bài 14 kết thúc bằng một phát hiện: cách cắt văn bản quyết định câu trả lời đúng hay sai. Bài này biến nhận xét đó thành phép đo. Chúng ta so sánh 3 chiến thuật Chunking trên hai loại văn bản khác nhau để thấy chiến thuật nào thắng ở đâu, tự cài đặt đồ thị tìm kiếm xấp xỉ kiểu HNSW rồi đo xem nó thực sự tiết kiệm bao nhiêu và đánh đổi những gì, và cuối cùng dựng Hybrid Search với RRF (Reciprocal Rank Fusion) trên một bộ tài liệu mà mỗi bộ tìm kiếm riêng lẻ đều hỏng ở một chỗ.
ollama pull bge-m3); thiếu nó chương
trình vẫn chạy và nói rõ đã bỏ qua phần nào. Lưu ý thời gian chạy: mục 15.2 dựng đồ thị cho 5.000 rồi 20.000 vector bằng Python thuần, nên mất khoảng một tới hai phút. Đây là cái giá của việc nhìn thấy thuật toán thay vì gọi thư viện C++.
Kiến thức cần có: Bài 14 cho toàn bộ đường ống RAG, Cosine Similarity và TF-IDF — bài này nâng cấp đúng bước 2 (Chunking) và bước 4 (Retrieval) của đường ống đó.
15.1 Các kiểu cắt đoạn văn bản (Chunking Strategies)
Việc chọn lựa chiến thuật cắt văn bản quyết định trực tiếp tới tỷ lệ tìm thấy thông tin chính xác (Recall rate) và độ loãng ngữ cảnh. Nếu chunk quá lớn, LLM sẽ bị loãng thông tin. Nếu chunk quá nhỏ, ngữ cảnh bổ trợ sẽ bị đứt gãy.
Ba chiến thuật Chunking phổ biến nhất hiện nay:
- Fixed-size Chunking (Cắt theo kích thước cố định): Cắt văn bản cơ học đúng $N$ ký tự hoặc từ. Phương pháp này siêu nhanh nhưng thường xuyên chẻ đôi câu, cắt ngang các số liệu quan trọng hoặc tên riêng — đúng lỗi mà Bài 14 đã đo được.
-
Recursive Character Chunking (Cắt đệ quy thông minh): Chia nhỏ văn bản dựa trên danh
sách các ký tự phân tách có thứ tự ưu tiên giảm dần: ký tự xuống dòng kép
\n\n(đoạn văn), ký tự xuống dòng đơn\n(dòng), dấu chấm câu.(câu), dấu phẩy,, và cuối cùng là khoảng trắng. Nó cố gắng giữ các câu văn trọn vẹn nhất có thể trong giới hạn kích thước chunk. - Semantic Chunking (Cắt theo ngữ nghĩa): Phương pháp cao cấp hơn. Ta quét qua văn bản theo từng câu, tính vector embedding cho từng câu và đo độ tương đồng giữa các câu liên tiếp. Một ranh giới chunk mới được tạo ra ngay tại vị trí độ tương đồng đột ngột giảm xuống dưới một ngưỡng định sẵn.
Đo thử: chiến thuật nào thắng, và trên loại văn bản nào?
Dự án cuối bài chạy cả ba chiến thuật trên hai tài liệu có tính chất trái ngược nhau. Tài liệu thứ nhất là bản NDA gọn gàng, mỗi điều khoản một đoạn có dấu chấm câu đầy đủ:
=== Tidy document (NDA, clean punctuation) ===
query: Mức phạt tiền vi phạm rò rỉ dữ liệu là bao nhiêu?
fixed-size (120 chars) 6 chunks best 0.4108
-> 't vi phạm hành chính đối với trường hợp rò rỉ dữ liệu khách hàng'
recursive (punctuation) 6 chunks best 0.4529
-> '[Điều 2] Mức phạt vi phạm hành chính đối với trường hợp rò rỉ dữ'
semantic (word overlap) 6 chunks best 0.4529
-> '[Điều 2] Mức phạt vi phạm hành chính đối với trường hợp rò rỉ dữ'
semantic (real embeddings) 6 chunks best 0.4529
-> '[Điều 2] Mức phạt vi phạm hành chính đối với trường hợp rò rỉ dữ'
Ba chiến thuật "thông minh" cho kết quả giống hệt nhau ($0.4529$), và đều thắng
Fixed-size ($0.4108$ — đoạn của nó bắt đầu bằng 't vi phạm', tức chữ "Mức phạt" đã bị chẻ làm
đôi). Đây không có nghĩa Semantic Chunking vô dụng: văn bản NDA này có ranh giới câu trùng khít
với ranh giới ngữ nghĩa, nên cắt theo dấu câu đã là cắt theo nghĩa rồi. Ưu thế của phương pháp đắt tiền
hơn không xuất hiện, vì không có gì để nó cải thiện.
Tài liệu thứ hai là một bản ghi cuộc họp — không dấu chấm, không viết hoa, gõ vội. Đây là dạng dữ liệu rất hay gặp trong doanh nghiệp thật:
=== When does the strategy actually matter? ===
tidy : fixed-size 6 chunks / best 0.4108 recursive 6 chunks / best 0.4529
messy: fixed-size 4 chunks / best 0.5869 recursive 1 chunks / best 0.3553
Kết quả đảo ngược hoàn toàn. Trên bản ghi cuộc họp, bộ cắt theo dấu câu tìm không ra một dấu chấm nào nên gộp cả tài liệu thành đúng 1 chunk, và độ khớp tụt xuống $0.3553$ — thua chiến thuật ngu ngốc nhất trong file, Fixed-size với $0.5869$. Bài học rút ra không phải "chiến thuật nào tốt nhất", mà là: mọi chiến thuật cắt thông minh đều dựa vào một giả định về cấu trúc văn bản, và khi giả định đó sai thì chúng hỏng nặng hơn cả cách làm ngây thơ. Hãy nhìn dữ liệu thật của bạn trước khi chọn.
15.2 Cấu trúc bên trong của Vector Database: HNSW Indexing
Nếu kho tài liệu có 1 triệu chunks, việc tính Cosine Similarity tuyến tính (Exact Search) giữa câu hỏi và từng chunk sẽ mất $O(N)$ thời gian. Vì vậy các Vector Database (như ChromaDB, Pinecone, Qdrant) dùng cơ chế tìm kiếm láng giềng gần nhất xấp xỉ Approximate Nearest Neighbors (ANN) với thuật toán đồ thị phân tầng HNSW (Hierarchical Navigable Small World).
HNSW hoạt động dựa trên cấu trúc đồ thị đa tầng, tương tự như danh sách liên kết bỏ qua (Skip Lists):
- Tầng trên cùng (Tầng thưa): Chứa rất ít nút vector phân tán xa nhau. Khi tìm kiếm, thuật toán thực hiện các bước nhảy lớn để nhanh chóng định vị vùng lân cận tổng quan của vector câu hỏi.
- Các tầng dưới (Tầng dày dần): Mật độ các nút vector dày hơn. Thuật toán đi sâu xuống các tầng dưới để tinh chỉnh đường đi, thực hiện các bước nhảy ngắn hơn nhằm tìm chính xác các láng giềng gần nhất.
Điểm quan trọng nằm ở chữ xấp xỉ: thuật toán đi theo đồ thị nên chỉ nhìn vào một phần nhỏ
kho dữ liệu, và vì thế có thể bỏ sót vector gần nhất thật sự. Tham số điều khiển mức đánh đổi đó
thường được gọi là ef (kích thước hàng đợi ứng viên trong lúc tìm): ef
càng lớn thì càng ít bỏ sót nhưng càng tốn tính toán.
=== What an approximate index really costs and saves ===
brute force always computes N distances and is always exact.
N = 5000 vectors, 64 dimensions, 50 clusters
ef recall@1 distances vs brute
16 76% 468 10.7x
64 99% 821 6.1x
256 100% 3037 1.6x
N = 20000 vectors, 64 dimensions, 50 clusters
ef recall@1 distances vs brute
16 29% 613 32.6x
64 66% 1448 13.8x
256 100% 2990 6.7x
Bảng này nói ba điều mà công thức $O(\log N)$ không nói:
- Không có bữa trưa miễn phí. Ở $N = 5000$, muốn recall $100\%$ thì phải tính $3037$ khoảng cách — chỉ nhanh hơn quét toàn bộ $1.6$ lần. Toàn bộ phần "nhanh gấp nhiều lần" nằm ở chỗ bạn chấp nhận bỏ sót: $ef = 16$ cho tốc độ gấp $10.7$ lần nhưng recall rơi xuống $76\%$.
- Lợi thế lớn dần theo quy mô. Đây mới là điều đáng giá. Dữ liệu tăng 4 lần (5.000 → 20.000) mà số phép tính ở recall $100\%$ gần như không đổi ($3037 \to 2990$), nên tỷ lệ tiết kiệm nhảy từ $1.6$ lên $6.7$ lần. Ngoại suy tới hàng triệu vector thì khoảng cách này mới thành hàng trăm lần — và đó là lý do thật sự để dùng ANN.
- Tham số phải chỉnh theo kích thước dữ liệu. Cùng $ef = 64$: ở $N = 5000$ cho recall $99\%$, nhưng ở $N = 20000$ tụt còn $66\%$. Một cấu hình chạy tốt lúc thử nghiệm với 5.000 tài liệu sẽ âm thầm bỏ sót một phần ba kết quả khi kho tài liệu lớn lên — mà không có lỗi nào được ném ra.
15.3 Hybrid Search: Kết hợp ngữ nghĩa và từ khóa
Tìm kiếm ngữ nghĩa bằng Dense Embeddings rất giỏi nắm bắt đại ý câu hỏi nhưng lại thường thất bại khi người dùng tìm chính xác tên riêng, mã sản phẩm hoặc mã lỗi. Lý do rất trực tiếp: mô hình nhúng được huấn luyện để đưa những thứ gần nghĩa lại gần nhau, mà hai mã lỗi khác nhau đúng một chữ số thì gần nghĩa gần như tuyệt đối.
Giải pháp là Hybrid Search — chạy song song hai công cụ tìm kiếm độc lập:
- Dense Retrieval (Tìm ngữ nghĩa): Dùng mô hình Embeddings để đo khoảng cách góc Cosine Similarity.
- Sparse Retrieval (Tìm từ khóa): Dùng thuật toán BM25 (phát triển từ TF-IDF ở Bài 14, bổ sung bão hòa tần suất và chuẩn hóa độ dài tài liệu).
- $M$ là tập hợp các phương pháp tìm kiếm (Dense và Sparse).
- $r_m(d)$ là thứ hạng vị trí của tài liệu $d$ trong danh sách kết quả của phương pháp tìm kiếm $m$ (1-indexed).
- $k$ là hằng số làm mượt (Smoothing constant), thường được đặt mặc định là $60$ để giảm thiểu tác động của những tài liệu có thứ hạng quá thấp.
Đo thử: mỗi bộ tìm kiếm hỏng ở đâu?
Dự án dựng một kho hỗ trợ kỹ thuật nhỏ, trong đó có hai tài liệu gần như y hệt nhau, chỉ khác đúng một chữ
số trong mã lỗi: E-1042 cho model NX-200 và E-1024 cho model NX-300. Rồi chạy
hai câu hỏi qua cả ba bộ xếp hạng:
=== Hybrid search: where each retriever alone fails ===
exact fault code : E-1024
bm25 [ok ] doc1=1.777 doc0=0.649 doc2=0.000
dense [ok ] doc1=0.488 doc0=0.421 doc2=0.313
rrf [ok ] doc1=0.033 doc0=0.032 doc2=0.032
paraphrased symptom: máy của tôi phát ra tiếng ồn lớn ở phần làm mát
bm25 [MISS] doc2=1.232 doc0=0.649 doc1=0.649
dense [ok ] doc0=0.616 doc1=0.611 doc2=0.497
rrf [ok ] doc0=0.033 doc2=0.032 doc1=0.032
Với mã lỗi chính xác, cả hai đều tìm đúng, nhưng khoảng cách tới tài liệu đứng thứ hai mới là điều đáng nhìn: BM25 cho $1.777$ so với $0.649$ — dứt khoát. Mô hình nhúng cho $0.488$ so với $0.421$, tức chỉ hơn nhau $0.067$. Hai tài liệu khác nhau đúng một chữ số gần như không phân biệt được trong không gian ngữ nghĩa. Chỉ cần kho tài liệu lớn hơn một chút, thứ tự đó sẽ đảo.
Với câu diễn giải ("máy phát ra tiếng ồn ở phần làm mát" — không dùng một từ nào trong tài liệu như "quạt tản nhiệt" hay "kêu to"), BM25 chọn sai: nó nhặt tài liệu vệ sinh bộ lọc bụi chỉ vì trùng chữ "máy". Mô hình nhúng hiểu đúng ý và chọn tài liệu sự cố quạt.
Ở cả hai trường hợp, RRF chọn đúng — không phải vì nó thông minh hơn, mà vì nó chỉ cần một trong hai bộ tìm kiếm xếp tài liệu đúng lên đầu. Đó chính là giá trị của tìm kiếm lai: hai điểm mù không trùng nhau thì hợp nhất lại sẽ che được cả hai.
15.4 Dự án thực hành bài 15: Ba phép đo trong một file
File gồm đúng ba phần khớp với ba mục trên: bộ so sánh chunking chạy trên hai loại tài liệu; đồ thị navigable small world tự cài đặt kèm bộ đếm phép tính khoảng cách; và BM25 + Dense + RRF trên kho hỗ trợ kỹ thuật. Tất cả bằng thư viện chuẩn, trừ phần gọi mô hình nhúng qua Ollama.
"""Lesson 15 project: chunking strategies, ANN indexing and hybrid search.
Run: python3 chunking_evaluation.py
Optional: Ollama with an embedding model (`ollama pull bge-m3`) for parts 1c
and 3. Everything else is standard library only.
Three questions, each answered with a measurement rather than a claim:
1. Do the three chunking strategies actually differ? On what kind of text?
2. How much does an approximate index really save, and what does it cost?
3. When does keyword search beat semantic search, and vice versa?
"""
import heapq
import json
import math
import random
import re
import urllib.error
import urllib.request
OLLAMA = "http://localhost:11434"
EMBED_PREFERRED = ["bge-m3", "nomic-embed-text", "mxbai-embed-large"]
# A tidy document: every clause is its own well-punctuated paragraph.
NDA_DOCUMENT = """
Hợp đồng bảo mật thông tin (NDA) của JS-Tools quy định rõ:
[Điều 1] Mọi tài liệu thiết kế hệ thống và mã nguồn dự án đều được phân loại là Mật.
Nhân viên không được chia sẻ thông tin này ra ngoài dưới bất kỳ hình thức nào.
[Điều 2] Mức phạt vi phạm hành chính đối với trường hợp rò rỉ dữ liệu khách hàng lên tới 500,000,000 VND.
Hành vi vi phạm nghiêm trọng có thể dẫn đến việc chấm dứt hợp đồng lao động lập tức mà không bồi thường.
[Điều 3] Thời hạn hiệu lực của thỏa thuận bảo mật kéo dài 5 năm kể từ ngày chấm dứt hợp đồng làm việc tại công ty.
Mọi tranh chấp sẽ được giải quyết tại Tòa án Nhân dân Thành phố Hồ Chí Minh.
"""
# A messy document: a meeting transcript with no reliable sentence boundaries.
# This is the case where punctuation-based splitting has nothing to work with.
TRANSCRIPT_DOCUMENT = """
an ok vay minh chot lai phan deploy nhe ban build xong thi day len staging
truoc da dung day thang len prod nua nhe lan truoc bi roll back met lam
binh ukm ma cai server staging no het dung luong roi day
an vay thi don log di
binh ok de toi don
an chuyen khac nhe ve cai bao gia cho khach hang ben Q
binh cai do ben sales bao la ho muon giam 15 phan tram
an giam nhieu the a thoi de toi hop voi sep tuan sau roi quyet
"""
# ---------------------------------------------------------------------------
# Part 0 - shared helpers
# ---------------------------------------------------------------------------
def tokenize(text):
return re.findall(r"\b\w+\b", text.lower())
def bag_cosine(text1, text2):
"""Cosine similarity over raw word counts. No semantics, just overlap."""
counts1, counts2 = {}, {}
for token in tokenize(text1):
counts1[token] = counts1.get(token, 0) + 1
for token in tokenize(text2):
counts2[token] = counts2.get(token, 0) + 1
vocab = set(counts1) | set(counts2)
dot = sum(counts1.get(w, 0) * counts2.get(w, 0) for w in vocab)
norm1 = math.sqrt(sum(v * v for v in counts1.values()))
norm2 = math.sqrt(sum(v * v for v in counts2.values()))
if norm1 == 0 or norm2 == 0:
return 0.0
return dot / (norm1 * norm2)
def vector_cosine(v1, v2):
dot = sum(a * b for a, b in zip(v1, v2))
norm1 = math.sqrt(sum(a * a for a in v1))
norm2 = math.sqrt(sum(b * b for b in v2))
return 0.0 if norm1 == 0 or norm2 == 0 else dot / (norm1 * norm2)
def ollama_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_embed_model():
names = ollama_models()
for wanted in EMBED_PREFERRED:
for name in names:
if name == wanted or name.startswith(wanted + ":"):
return name
return None
def embed(text, model):
body = json.dumps({"model": model, "prompt": text}).encode("utf-8")
request = urllib.request.Request(
f"{OLLAMA}/api/embeddings", data=body,
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request) as response:
return json.loads(response.read())["embedding"]
# ---------------------------------------------------------------------------
# Part 1 - the three chunking strategies
# ---------------------------------------------------------------------------
def fixed_size_chunk(text, chunk_size=120):
"""Cut every chunk_size characters, regardless of what is there."""
return [text[i:i + chunk_size].strip()
for i in range(0, len(text), chunk_size)]
def recursive_character_chunk(text, chunk_size=120):
"""Split on punctuation first, then pack sentences up to chunk_size."""
sentences = [s for s in re.split(r"(?<=[.\n])\s+", text.strip()) if s]
chunks, current = [], ""
for sentence in sentences:
if current and len(current) + len(sentence) > chunk_size:
chunks.append(current.strip())
current = sentence
else:
current += " " + sentence
if current.strip():
chunks.append(current.strip())
return chunks
def semantic_chunk(text, threshold=0.20, similarity=bag_cosine):
"""Start a new chunk wherever consecutive sentences stop being similar.
`similarity` is injectable so the same function can run on word overlap
(no dependencies) or on real embeddings (part 1c).
"""
sentences = [s.strip() for s in re.split(r"(?<=[.\n])\s+", text.strip())
if s.strip()]
if not sentences:
return []
chunks, current = [], sentences[0]
for index in range(1, len(sentences)):
if similarity(sentences[index - 1], sentences[index]) < threshold:
chunks.append(current)
current = sentences[index]
else:
current += " " + sentences[index]
chunks.append(current)
return chunks
def best_match(query, chunks, score=bag_cosine):
scored = [(score(query, chunk), index) for index, chunk in enumerate(chunks)]
return max(scored)
def compare_chunkers(label, document, query, embed_model=None):
"""Run all strategies over one document and report what each retrieves."""
print(f"=== {label} ===")
print(f" query: {query}")
strategies = [
("fixed-size (120 chars)", fixed_size_chunk(document, 120)),
("recursive (punctuation)", recursive_character_chunk(document, 120)),
("semantic (word overlap)", semantic_chunk(document, 0.20)),
]
if embed_model:
cache = {}
def embed_similarity(a, b):
for text in (a, b):
if text not in cache:
cache[text] = embed(text, embed_model)
return vector_cosine(cache[a], cache[b])
strategies.append(("semantic (real embeddings)",
semantic_chunk(document, 0.55, embed_similarity)))
results = {}
for name, chunks in strategies:
score, index = best_match(query, chunks)
results[name] = (len(chunks), score, chunks[index])
print(f" {name:<28} {len(chunks):2} chunks best {score:.4f}")
print(f" {'':<28} -> {chunks[index][:64]!r}")
print()
return results
# ---------------------------------------------------------------------------
# Part 2 - what an approximate index actually buys
# ---------------------------------------------------------------------------
def squared_distance(a, b):
return sum((x - y) ** 2 for x, y in zip(a, b))
def build_graph(vectors, neighbours=16, sample=200):
"""A navigable small-world graph: every node linked to near neighbours.
Real HNSW stacks several of these graphs in layers and uses a smarter
neighbour-selection heuristic. This single layer is enough to show where
the saving comes from, and where it stops.
"""
graph = {i: set() for i in range(len(vectors))}
for i in range(len(vectors)):
candidates = random.sample(range(len(vectors)),
min(len(vectors), sample))
nearest = sorted((squared_distance(vectors[i], vectors[j]), j)
for j in candidates if j != i)[:neighbours]
for _, j in nearest:
graph[i].add(j)
graph[j].add(i)
return graph
def graph_search(vectors, graph, query, ef=64, entry=0):
"""Greedy best-first walk. Returns (best index, distance computations)."""
calls = [0]
def distance_to(i):
calls[0] += 1
return squared_distance(query, vectors[i])
visited = {entry}
candidates = [(distance_to(entry), entry)]
best = [(-candidates[0][0], entry)]
while candidates:
current_distance, current = heapq.heappop(candidates)
if -best[0][0] < current_distance and len(best) >= ef:
break # everything left in the queue is worse than what we hold
for neighbour in graph[current]:
if neighbour in visited:
continue
visited.add(neighbour)
neighbour_distance = distance_to(neighbour)
if len(best) < ef or neighbour_distance < -best[0][0]:
heapq.heappush(candidates, (neighbour_distance, neighbour))
heapq.heappush(best, (-neighbour_distance, neighbour))
if len(best) > ef:
heapq.heappop(best)
return sorted((-d, i) for d, i in best)[0][1], calls[0]
def make_clustered_vectors(count, dimension=64, clusters=50, spread=0.25):
"""Real embeddings sit in clusters, not spread evenly. Mimic that."""
centres = [[random.gauss(0, 1) for _ in range(dimension)]
for _ in range(clusters)]
vectors = [[x + random.gauss(0, spread) for x in centres[i % clusters]]
for i in range(count)]
return centres, vectors
def measure_index(count, ef_values, trials=100):
"""Recall and distance computations against an exact brute-force scan."""
random.seed(42)
centres, vectors = make_clustered_vectors(count)
graph = build_graph(vectors)
rows = []
for ef in ef_values:
hits, total_calls = 0, 0
for _ in range(trials):
centre = centres[random.randrange(len(centres))]
query = [x + random.gauss(0, 0.25) for x in centre]
exact = min(range(count),
key=lambda i: squared_distance(query, vectors[i]))
found, calls = graph_search(vectors, graph, query, ef=ef)
hits += found == exact
total_calls += calls
rows.append((ef, hits / trials, total_calls / trials,
count / (total_calls / trials)))
return rows
def report_index():
print("=== What an approximate index really costs and saves ===")
print(" brute force always computes N distances and is always exact.\n")
for count in (5000, 20000):
print(f" N = {count} vectors, 64 dimensions, 50 clusters")
print(f" {'ef':>5} {'recall@1':>9} {'distances':>10} {'vs brute':>9}")
for ef, recall, calls, speedup in measure_index(count, (16, 64, 256)):
print(f" {ef:>5} {recall:>8.0%} {calls:>10.0f} {speedup:>8.1f}x")
print()
# ---------------------------------------------------------------------------
# Part 3 - hybrid search: BM25, dense, and RRF fusion
# ---------------------------------------------------------------------------
# Two documents that differ by a single digit in the fault code, plus two
# unrelated ones. This is the shape of a real support knowledge base.
SUPPORT_DOCS = [
"Sự cố mã E-1042: quạt tản nhiệt của model NX-200 kêu to bất thường. "
"Thay quạt theo quy trình bảo hành.",
"Sự cố mã E-1024: quạt tản nhiệt của model NX-300 kêu to bất thường. "
"Thay quạt theo quy trình bảo hành.",
"Hướng dẫn vệ sinh bộ lọc bụi định kỳ ba tháng một lần cho toàn bộ dòng "
"máy NX.",
"Chính sách hoàn tiền áp dụng trong vòng 30 ngày kể từ ngày mua hàng.",
]
def bm25_scores(query, documents, k1=1.5, b=0.75):
"""Classic BM25: TF saturation plus length normalisation."""
tokenised = [tokenize(d) for d in documents]
average_length = sum(len(d) for d in tokenised) / len(tokenised)
scores = []
for document in tokenised:
score = 0.0
for term in tokenize(query):
frequency = document.count(term)
if frequency == 0:
continue
containing = sum(1 for d in tokenised if term in d)
idf = math.log(1 + (len(tokenised) - containing + 0.5)
/ (containing + 0.5))
norm = 1 - b + b * len(document) / average_length
score += idf * frequency * (k1 + 1) / (frequency + k1 * norm)
scores.append(score)
return scores
def ranks_from_scores(scores):
"""Position of each document, 1 = best."""
order = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
ranks = [0] * len(scores)
for position, index in enumerate(order, start=1):
ranks[index] = position
return ranks
def reciprocal_rank_fusion(rank_lists, k=60):
"""Combine rankings without ever comparing incomparable score scales."""
return [sum(1 / (k + ranks[i]) for ranks in rank_lists)
for i in range(len(rank_lists[0]))]
def report_hybrid(embed_model):
print("=== Hybrid search: where each retriever alone fails ===")
if embed_model is None:
print(" skipped - no embedding model installed"
" (`ollama pull bge-m3`)\n")
return
document_vectors = [embed(d, embed_model) for d in SUPPORT_DOCS]
queries = [
("exact fault code ", "E-1024", 1),
("paraphrased symptom", "máy của tôi phát ra tiếng ồn lớn ở phần làm mát", 0),
]
for label, query, expected in queries:
sparse = bm25_scores(query, SUPPORT_DOCS)
query_vector = embed(query, embed_model)
dense = [vector_cosine(query_vector, v) for v in document_vectors]
fused = reciprocal_rank_fusion(
[ranks_from_scores(sparse), ranks_from_scores(dense)])
print(f" {label}: {query}")
for name, scores in (("bm25 ", sparse), ("dense", dense),
("rrf ", fused)):
ordered = sorted(range(len(scores)), key=lambda i: scores[i],
reverse=True)
mark = "ok " if ordered[0] == expected else "MISS"
top_scores = " ".join(f"doc{i}={scores[i]:.3f}"
for i in ordered[:3])
print(f" {name} [{mark}] {top_scores}")
print()
print(" On the exact code both find it, but look at the gap to the")
print(" runner-up: BM25 is decisive, while the embedding barely separates")
print(" E-1024 from E-1042 - two documents one digit apart.")
print(" On the paraphrase BM25 picks the wrong document; RRF follows the"
" retriever")
print(" that was right, without ever comparing the two score scales")
print(" directly. RRF numbers are always small and close together: with")
print(" k=60, rank 1 scores 1/61 and rank 2 scores 1/62. Only the order")
print(" matters, never the magnitude.\n")
def main():
embed_model = pick_embed_model()
if embed_model:
print(f"Embedding model in use: {embed_model}\n")
else:
print("No embedding model found; parts 1c and 3 will be skipped.\n")
tidy = compare_chunkers("Tidy document (NDA, clean punctuation)",
NDA_DOCUMENT,
"Mức phạt tiền vi phạm rò rỉ dữ liệu là bao nhiêu?",
embed_model)
messy = compare_chunkers("Messy document (meeting transcript, no periods)",
TRANSCRIPT_DOCUMENT,
"khach hang ben Q muon giam gia bao nhieu phan tram?",
embed_model)
# On tidy text, punctuation already sits on the semantic boundaries, so the
# two strategies agree. That is a property of the text, not of the method.
print("=== When does the strategy actually matter? ===")
for label, results in (("tidy ", tidy), ("messy", messy)):
fixed = results["fixed-size (120 chars)"]
recursive = results["recursive (punctuation)"]
print(f" {label}: fixed-size {fixed[0]} chunks / best {fixed[1]:.4f}"
f" recursive {recursive[0]} chunks / best {recursive[1]:.4f}")
print(" On tidy text the punctuation already sits on the semantic")
print(" boundaries, so every smart strategy agrees and beats fixed-size.")
print(" On the transcript there is no sentence punctuation at all, so the")
print(" recursive splitter collapses to a single chunk and loses to the")
print(" dumbest strategy in the file.\n")
report_index()
report_hybrid(embed_model)
if __name__ == "__main__":
main()
Cách chạy dự án này trên máy bạn
-
Chạy
python3 chunking_evaluation.py. Không cần cài thư viện. Muốn có mục Semantic Chunking bằng embedding thật và mục Hybrid Search thì bật Ollama kèmollama pull bge-m3. -
Chờ khoảng một tới hai phút ở mục chỉ mục — nó dựng đồ thị cho 25.000 vector bằng Python thuần. Các con
số recall và số phép tính khoảng cách sẽ giống hệt trong bài nhờ
random.seed(42). -
Rồi thử phá nó theo ba cách:
-
Trong
make_clustered_vectors, đổiclusters=50thành1— dữ liệu không còn cụm, trở thành phân bố đều. Recall tụt thảm hại ở cùngef: tìm kiếm xấp xỉ sống được là nhờ dữ liệu thật có cấu trúc cụm, chứ không phải nhờ phép màu của đồ thị. -
Trong
build_graph, hạneighbours=16xuống4. Đồ thị thưa đi, số phép tính giảm nhưng recall giảm mạnh hơn — đây là tham số $M$ mà mọi vector database đều bắt bạn chọn lúc tạo chỉ mục, và không sửa được sau đó. -
Trong
reciprocal_rank_fusion, đổik=60thành1. Điểm RRF giãn ra rất rộng và thứ hạng 1 áp đảo hoàn toàn, khiến kết quả gần như bằng "lấy theo bộ tìm kiếm nào tự tin nhất" — mất đi tính hợp nhất mà $k$ lớn mang lại.
-
Trong
Tóm tắt bài học & Cầu nối kiến thức
- Đạt được: Đo được rằng chiến thuật chunking "thông minh" chỉ thắng khi văn bản có cấu trúc đúng như nó giả định — trên bản ghi cuộc họp, cắt theo dấu câu gộp thành 1 chunk và thua cả cắt cứng theo ký tự.
- Đạt được: Tự cài đặt đồ thị tìm kiếm xấp xỉ và đo được đường cong đánh đổi thật: recall $100\%$ chỉ nhanh hơn $1.6$ lần ở $N=5000$, nhưng lên $6.7$ lần ở $N=20000$ — lợi thế nằm ở quy mô, không phải ở một con số cố định.
-
Đạt được: Thấy cùng một cấu hình
ef=64cho recall $99\%$ ở 5.000 vector nhưng chỉ $66\%$ ở 20.000 vector — dạng suy giảm âm thầm không ném ra lỗi nào. - Đạt được: Dựng Hybrid Search với RRF, và đo được điểm mù của từng bên: embedding chỉ hơn $0.067$ giữa hai mã lỗi khác nhau một chữ số, còn BM25 chọn sai hoàn toàn khi câu hỏi không dùng từ nào trong tài liệu.
Cầu nối bài tiếp theo: Bài này cải thiện cách lưu và cách tìm. Nhưng khi câu hỏi của người dùng mơ hồ ngay từ đầu, hoặc khi tài liệu đúng nằm ở hạng 5 chứ không phải hạng 1, thì không bộ chỉ mục nào cứu được. Bài 16 xử lý đúng hai chỗ đó: viết lại câu hỏi (Query Rewriting) và sắp xếp lại kết quả bằng Cross-Encoder (Reranking).
Tải file code thực hành minh họa bài học
File Python chunking_evaluation.py — mã nguồn triển khai các bộ phân mảnh văn bản Fixed,
Recursive, Semantic và bộ máy tìm kiếm vector so sánh Recall (chạy
python chunking_evaluation.py):
📖 Tài liệu tham khảo
- Efficient and Robust Approximate Nearest Neighbor Search using Hierarchical Navigable Small World Graphs — Bài báo khoa học nguyên bản đề xuất thuật toán đồ thị HNSW (Malkov & Yashunin, 2016)
- Reciprocal Rank Fusion Outperforms Single Retrieval Methods and Hybrid Search Combination — Bài báo khoa học nghiên cứu thuật toán xếp hạng lai RRF (Cormack et al., 2009)
- LangChain Document Transformers — Tài liệu hướng dẫn chi tiết các chiến thuật phân đoạn văn bản chuyên nghiệp (LangChain Documentation)
Bình luận