LLM Wiki cá nhân (Kỳ 1) — Distill thay vì chunk-and-vector
Series 4 kỳ về cách dựng một second-brain dùng được hằng ngày mà không cần vector DB. Kỳ 1 đặt nền: vì sao “RAG kiểu chunk + cosine” không phải lúc nào cũng đúng, và một LLM Wiki theo gợi ý của Andrej Karpathy được tổ chức như thế nào.
Vì sao RAG kiểu “chunk + vector” không phải lúc nào cũng đúng
Phần lớn tutorial “chat với ghi chú của bạn” có cùng một công thức:
- Cắt tài liệu thành chunk 500–1000 token.
- Embed mỗi chunk vào vector DB.
- Khi hỏi: embed câu hỏi → cosine top-k → nhồi vào prompt → LLM trả lời.
Công thức này chạy được, nhưng có hai điểm yếu:
Similarity không đồng nghĩa với relevance. Một đoạn có embedding gần câu hỏi chưa chắc chứa câu trả lời. Đặc biệt khi câu hỏi đòi suy luận (“so sánh A với B”, “tổng kết các bước”), top-k thường trả về những chunk nghe giống nhưng không trả lời. PageIndex (github.com/VectifyAI/PageIndex) gọi đây là “vector RAG fails when retrieval requires reasoning”.
Chunk boundary là ngẫu nhiên. Bạn không kiểm soát được chỗ cắt — chunk hay rơi giữa câu, giữa luận điểm. LLM thấy mảnh vụn, lắp ghép bằng kiến thức ngoài, kết quả là một câu trả lời nghe trôi chảy nhưng hallucinate dữ kiện. Càng nhiều tài liệu, vấn đề càng nặng.
Ý tưởng của Karpathy
Andrej Karpathy đăng một idea ngắn: thay vì lưu raw text rồi truy hồi bằng similarity, để chính LLM distill nguồn thành một trang wiki có cấu trúc — rồi về sau hỏi LLM trên trang wiki đó. Tri thức đã được cô đọng sẵn, không cần kỹ thuật retrieval tinh vi.
Hai thay đổi gốc:
- Đơn vị lưu trữ là trang khái niệm, không phải chunk. Mỗi trang gói gọn một idea (ví dụ: “RAG là gì”, “Bi-temporal schema”), được LLM viết lại sạch sẽ với heading, bullet, link.
- “Retrieval” được thay bằng dump cả wiki vào prompt. Khi wiki còn nhỏ (≤ vài trăm trang), 80k token context của các LLM hiện nay nuốt gọn — không cần embedding, không cần vector DB. Khi lớn hơn thì có vài kỹ thuật mở rộng vẫn không cần vector (sẽ nói ở kỳ 4).
Hệ quả: bạn có một KB con người đọc được, audit được, sửa được tay. Không phải cục nén binary chỉ máy hiểu.
Layout ba thư mục
wiki-root/
raw/ # nguồn gốc — immutable, content-addressed bằng sha256
wiki/ # trang đã distilled — 1 page = 1 file .json
log.md # append-only nhật ký mọi thao tác
Tại sao tách:
raw/là gốc audit. Không bao giờ sửa. Tên file =<sha256-ngắn>_<slug>.<ext>để cùng nội dung không lưu 2 lần và phát hiện drift khi nguồn ngoài thay đổi.wiki/là nơi LLM đọc khi user hỏi. Edit được, supersede được, archive được.log.mdghi{ts, action, title, ...}mỗi dòng JSON, append-only. Khi có gì sai, tua lại được.
Một trang wiki trông như thế nào
{
"title": "Retrieval-Augmented Generation",
"summary": "Kết hợp truy hồi tài liệu với sinh để LLM trả lời có nguồn.",
"content": "## RAG là gì\nRAG ghép retrieval và generation...\n\n## Vector RAG vs Reasoning RAG\n...",
"tags": ["rag", "llm", "retrieval"],
"links": ["Vector DB", "Embedding"],
"domain": "Learning",
"source": "Lewis et al. 2020",
"source_authority": 0.95,
"confidence": 0.9,
"ingested_at": "2026-05-10T...",
"updated_at": "2026-05-20T..."
}
Ba điểm đáng để ý:
contentlà markdown LLM viết lại từ nguồn, không phải copy nguyên gốc. Sạch, có heading, có[[wikilink]]trỏ trang khác.source_authority(0–1) gắn vào nguồn: docs chính thức = 1.0, paper = 0.9, blog = 0.5, “ai đó nói trên Twitter” = 0.3.confidenceđánh giá chính nội dung trang (LLM tự cho). Hai con số này về sau giúp LLM cảnh báo “Trang này confidence thấp” khi trả lời.tags+domain: phân loại để filter/scope nhanh.domainđơn lẻ (Work/Learning/Personal…),tagsđa giá trị, không kiểm soát.
Ingest pipeline — LLM distills, không chunks
Bốn bước:
raw text / PDF / URL
↓ lưu vào raw/<sha>_<slug>.txt (content-addressed)
↓
LLM compile prompt:
"Đọc nguồn → tạo 1–3 trang wiki, mỗi trang 1 khái niệm,
dùng [[wikilink]] tham chiếu trang đã có."
↓
JSON: { pages: [{title, content, tags, links}, ...] }
↓
save_page (upsert theo title) → wiki/<slug>.json + log.md
Khung backend tối giản:
async def ingest_text(content: str, source_label: str, authority: float):
sha = sha256(content)
raw_ref = save_raw(content, source_label, sha)
pages = await llm_compile_to_pages(content, source_label, authority)
# LLM trả về: [{"title": "...", "content": "## ...",
# "tags": [...], "links": [...]}, ...]
saved = []
for p in pages:
await save_page({**p,
"source_sha256": sha,
"source_authority": authority})
saved.append(p["title"])
return saved
Prompt compile (rút gọn):
Bạn là biên dịch viên wiki. Đọc nguồn dưới và tạo 1–3 trang.
Quy tắc:
- Mỗi trang = 1 khái niệm cốt lõi. Không trang vạn năng.
- Headings (##, ###), bullet, ví dụ ngắn.
- Dùng [[Tên khái niệm]] để link sang trang khác. ƯU TIÊN link tới
trang đã có (xem danh sách "Trang đã có" bên dưới).
- KHÔNG bịa. Chỉ ghi điều có trong nguồn.
- confidence = 0.9 nếu nguồn rõ; 0.6 nếu mơ hồ.
Trang đã có: {{titles}}
Nguồn (authority = {{authority}}):
{{raw_text}}
Trả về CHỈ JSON:
{"pages":[{"title":"...","content":"## ...","tags":[...],
"links":[...],"confidence":0.85}]}
Hai chú ý thực dụng:
- LLM JSON dễ vỡ. Dùng
tolerant_json_loads(4 lần thử với các pattern repair) thay vìjson.loadsthuần. Gemma/Llama hay phun ra\$,\(lạc — fix chúng trước khi parse. - Context limit của LLM. Nguồn dài (PDF 50 trang) đưa hết vào 1 lần compile = chất lượng tụt. Giải pháp: cắt theo cấu trúc tự nhiên (TOC, heading) — kỳ 4 sẽ nói.
[[Wikilinks]] làm tri thức tự đan vào nhau
Bài học ở wiki: graph quan trọng hơn list. Mỗi trang có thể chèn [[Tên trang]] để trỏ sang trang khác. Render-time:
const wikiRe = /\[\[([^\]]+)\]\]/g;
const html = text.replace(wikiRe, (_, t) =>
`<span class="wikilink" data-page="${t}">${t}</span>`);
Click vào → mở trang đó. Khi LLM compile, prompt yêu cầu ưu tiên link tới trang đã có (danh sách existing_titles truyền vào prompt). Càng dùng càng dày — đây là “retrieval miễn phí”: khi bạn đọc trang RAG, bạn thấy link sang [[Vector DB]], click một phát là chuyển — không cần search, không cần embedding.
Một chi tiết tinh tế: nếu [[Tên]] chưa có trang tương ứng, render thành link “broken” (màu khác). Đó là gợi ý viết bài mới — KB tự tạo todo list cho bạn.
Save-page với “update-in-place”
Trùng tên → merge, đừng tạo “v2”:
async def save_page(page: dict):
title = page["title"]
existing = get_page(title)
now_ts = now()
merged = {
"title": title,
"summary": page.get("summary", ""),
"content": page["content"], # ghi đè bằng bản LLM mới
"tags": page.get("tags", []),
"links": page.get("links", []),
"source_authority": page["source_authority"],
# Giữ nguyên dấu vết
"ingested_at": (existing or {}).get("ingested_at", now_ts),
"updated_at": now_ts,
# ... (các trường bi-temporal — kỳ 2)
}
page_path(title).write_text(json.dumps(merged, ensure_ascii=False))
append_log("update" if existing else "create", title=title)
Update-in-place là một quyết định nguyên tắc: tránh ngập trong “RAG”, “RAG v2”, “RAG cuối cùng”, “RAG mới (thật)”. Mỗi khái niệm → đúng một trang. Phiên bản → ở log.md + cơ chế bi-temporal sẽ giới thiệu ở kỳ 2.
Khi nào pattern này phù hợp — và khi nào không
Phù hợp:
- KB cá nhân hoặc team nhỏ, < 1000 trang. 80k token context dump nguyên wiki vẫn ok.
- Domain cần kết nối khái niệm (graph), không phải lookup chính xác.
- Bạn muốn KB con người đọc được, sửa được tay, audit được.
- Tài liệu nguồn tương đối “narrative” (paper, blog, transcript) — distill có ý nghĩa.
Không phù hợp:
- KB cực lớn (triệu doc) → tiền LLM compile khổng lồ.
- Cần lookup exact (bảng giá, mã sản phẩm) → SQL hợp hơn.
- Nguồn highly-structured (CSV, API logs) → không cần distill.
Khi wiki vượt ~80k token, ta có vẫn không cần vector — kỳ 4 sẽ nói về TOC-driven ingest (mỗi section một trang) và domain scope (LLM chỉ đọc đúng domain user hỏi).
Trước khi sang kỳ sau, hãy thử ngay
Cài tối thiểu cần để chạy mockup này trong vài giờ:
- 1 LLM endpoint OpenAI-compat (OpenAI, Anthropic, Gemma, Ollama đều được).
- Filesystem (raw/, wiki/, log.md). Local máy bạn là đủ.
- 200 dòng Python cho
ingest_text+save_page+ một loop chat dump-wiki-vào-prompt.
Bắt đầu nhỏ: lấy 3–5 blog post bạn vừa đọc tuần này, ingest, hỏi vài câu. Bạn sẽ thấy điều khó tin: LLM trả lời nuột hơn cả khi bạn đưa raw text vào, vì nó đã được chính LLM đọc và distill trước.
Sneak peek 3 kỳ tới
- Kỳ 2 — Forget ≠ Delete: bi-temporal schema, supersede, decay_scan. Vì sao “đừng xoá, hãy gắn cờ stale”. Bài bạn viết tháng 3 năm ngoái có còn đúng?
- Kỳ 3 — Cite-or-Refuse: 2-pass enforcement. Làm LLM thật sự trả lời từ wiki của bạn, không lấy lén kiến thức ngoài.
- Kỳ 4 — Nhân rộng: multi-user share + TOC-driven ingest. Khi wiki vượt context, và khi 2 người cùng dùng chung KB.
Code mẫu trong bài tham khảo từ một implementation thực của tác giả (GreenNode AgentBase + Notion + HuggingFace Space). Tinh thần thì stack-agnostic: bạn cast sang FastAPI + SQLite + file local cũng được, không thay đổi tư duy.

