TL;DR — Tôi build 1 RAG cá nhân (~6,000 documents, ~57K chunks) qua 4 phiên bản trong 9 ngày. Phiên bản cuối chạy local trên MacBook Pro M2 Max: warm latency p50 ~0.92s, top-1 score 0.95+ cho query thông thường, $0/tháng, hybrid retrieval (dense + sparse + ColBERT) + cross-encoder rerank. Bài này tôi log lại từng version, đo gì, đánh đổi gì, học được gì.
Bối cảnh
Tôi có nhiều nguồn tri thức rời rạc: Confluence (~3.6K pages), Jira (~534 tickets), Slack threads, Krisp/Granola meeting transcripts, Gmail, Claude Code conversation logs (~270 sessions/tháng), markdown notes ở ~/Documents/. Vấn đề: 90% kiến thức đã thu nạp không truy xuất được khi cần. Spotlight chỉ exact-match. MCP per-source quá chậm. Mỗi conversation Claude tốn 40-80K tokens cho grep + Read.
Goal ban đầu rất đơn giản: hỏi “ai từng nói gì về X?” qua Claude → trả top 5 đoạn relevant + link source trong <3 giây.
Tôi để goal đó dẫn dắt 4 lần pivot dưới đây.
V1 — Cloud ADB SIN (Day 1-8)
Stack: GCP VM e2-standard-4 SIN + Oracle Autonomous DB 23ai (vector type built-in, Always Free) + FastMCP Streamable HTTP server + Cloudflare named tunnel mcp.vuihoc.ai + OAuth 2.0 (PKCE + DCR + password) + legacy bearer fallback.
Embedder: ban đầu BGE-small-en-v1.5 384d — đến Day 3b phải pivot sang multilingual-e5-small vì query tiếng Việt score chỉ 0.69-0.77. Sau khi đổi: 0.85+.
Số đo M1:
| Metric | Target | Achieved |
|---|---|---|
| Hit@5 | ≥60% | ~85% (subjective trên 5 query thật) |
| Latency p95 | <5s | 1.16s |
| Sources | 100 | 5,329 / 47K chunks |
| Touchpoint | Telegram | MCP — Claude Desktop + web + iOS |
Pivot lớn nhất ở V1: bỏ Telegram bot, chuyển sang MCP. Lý do: Claude clients (Desktop/web/iOS) tự gọi tools qua MCP, không cần build bot UI riêng. Same UX, less code, free mobile access.
Điểm mạnh: ship nhanh (~22h), free tier (ADB 20GB + Cloudflare + GCP credit), public access từ mọi Claude client.
Điểm yếu: Mac của tôi ở Vietnam, ADB ở Singapore → cross-region 200ms phí mỗi query. BGE-en mặc định kém VN. Pure dense retrieval, không rerank — semantic OK nhưng không catch được edge cases.
V2 — pgvector replicate (Day 10)
Vấn đề V1: 200ms cross-region khó accept. Thay vì tune embedder, tôi attack latency.
Migrate ADB SIN → self-host pgvector trên GCP e2-micro US (Always Free) → rồi replicate sang asia-southeast1 (paid via credit). Cùng tunnel-id, không đổi DNS.
Đổi luôn embedder sang Voyage 512d API vì lúc đó nghĩ “API giảm RAM VM, đỡ phải maintain model”.
Kết quả latency: p50 700-850ms → 148-229ms warm (~-72%).
Vấn đề chất lượng: Voyage 512d kém e5-small cho mixed VN/EN query. Top-1 score regression: ~0.85 (e5) → ~0.67 (Voyage). Một số query VN trả về kết quả không liên quan.
Bài học V2: Latency optimization mà sacrifice quality là dại dột. Người dùng (chính tôi) chấp nhận 700ms cho score 0.85, không chấp nhận 150ms cho score 0.67. Tốc độ chỉ matter sau khi answer đúng.
V2 dùng được vài ngày, rồi tôi quyết định pivot lớn hơn: bỏ cloud hoàn toàn.
V3 — Mac M2 Max local (Day 11-13)
Premise: RAG cá nhân không cần cloud. Mac luôn-on, data cá nhân, tại sao phải round-trip Internet?
Hardware tôi đang dùng: MacBook Pro M2 Max, 12 cores (8P+4E), 64GB RAM. Plenty of headroom cho heavy stack.
Stack mới:
- Embedder: bge-m3 1024d — đặc biệt vì sản xuất 3 representation trong 1 forward pass: dense vector, sparse vector (token weights), và ColBERT-style multi-vector. Top quality multilingual MIRACL ~76%.
- Reranker: bge-reranker-v2-m3 cross-encoder — score lại top-N candidates sau retrieval.
- Fusion: RRF (Reciprocal Rank Fusion) merge dense + sparse rankings.
- DB: Postgres 16 + pgvector (build từ source vì brew formula chỉ ship cho v17/v18).
- Server: HTTP daemon port 8080 thay vì stdio MCP. Lý do quan trọng: 3 client (Claude Desktop + KB ingest hook + future iOS) share cùng daemon → 1× model RAM thay 3× (2.5GB vs 7.5GB).
- Public access: Cloudflare tunnel reuse cùng tunnel-id →
mcp.vuihoc.aitrỏ về Mac local. Mobile/web vẫn vào được.
Re-ingest toàn bộ KB mất 17h49m → 5,950 sources / 56,474 chunks / 900MB DB.
Bug fix đáng nhớ V3:
Starlette wrapperăn mất MCP lifespan → phải dùngmcp.streamable_http_app()direct.transformers 5.7.0removedXLMRobertaTokenizer.prepare_for_model→ downgrade về 4.57.6.- pgvector HNSW + WHERE filter trên JOIN trả về 0 rows — fix bằng
SET hnsw.iterative_scan = relaxed_order+ef_search = 200. Cầnautocommit=Trueđể SET session-level. top_k silent cap: RRF_TOP=5 fixed → top_k=10 vẫn trả 5. Fix:rerank_pool_n = min(25, max(5, top_k * 2))dynamic.
Latency thực đo (regression 10 query, 2026-05-06):
| Metric | Giá trị |
|---|---|
| Cold call | 1.97s |
| Warm avg | 1.06s (10 queries) |
| Warm p50 (test 2-10) | ~0.92s |
| Top-1 score median | 0.96 |
| Pass rate | 10/10 |
Quality jump nhìn rõ: query về “Conversation về Hermes self-improve agent” (mixed VN/EN) — V2 Voyage không trả được top hit relevant; V3 bge-m3 + rerank trả top-1 score 0.96.
V4 — Multimodal + structured polish (Day 14-15)
V3 stable rồi, tôi bắt đầu lấp các gap về coverage:
Phase 2 — Office docs: 4 dispatcher (pypdf, python-docx, pandas cho xlsx, python-pptx) trong extract_office.py. Một số PDF (UOB GIRO bank specs) ingested → 32 chunks. Top-1 query “UOB Bulk FAST GIRO format” → 0.9958.
Phase 3 — image-only PDF: 2 PDF sample bị empty text (image-only). Pipeline: pdftoppm -r 300 render → macOS Vision OCR (free, on-device) → markdown sidecar → ingest. 1 + 25 chunks added.
Phase 4 — image sidecar 100% coverage: Bắt đầu 6,446/6,589 PNG có sidecar. 143 PNG còn lại: 41 real PNG (OCR), 11 mis-named PDF (render + OCR), 91 mis-named non-image (text/CSV/JSON/zip — fallback handler). VLM cost (Claude Haiku fallback): $0.07 cho 37 calls (budget cap $8 — tiny vì hầu hết file không phải image thật).
Structured retrieval — kb_my_action_items: Regex parser cho - [ ] **[CATEGORY]** Name — Task ... 📅 Added: .... Multi-source: Action_Items.md master + Meetings/meetings/*.md. NAME_ALIASES (Trí/Tri/Marc resolve cùng person). Filter: name, status, since_days, category. Latency <100ms vì không embedding/rerank — chỉ regex + filter.
Confluence delta sync: Audit cloud spaces (34) vs local crawled (33). CQL lastmodified >= last_sync → 83 pages updated. Trừ Marc-authored (đã ingest qua skill) + personal spaces → 55 pages others-authored fetch + ingest qua filesystem-first pipeline. ~30 phút end-to-end.
Security — OAuth /login rate limit: Sliding 10-min window, 5 failures/IP → 15-min hard lockout (429 + Retry-After). IP từ X-Forwarded-For first hop fallback request.client.host. In-memory deque + threading lock.
Final state:
| Metric | V1 | V4 (hiện tại) |
|---|---|---|
| Sources | 5,329 | 6,014+ |
| Chunks | 47K | 57K+ |
| Coverage | .md only | .md + PDF + Office + 100% PNG (OCR + VLM) + structured action items |
| Embedder | e5-small 384d | bge-m3 1024d hybrid + reranker v2-m3 |
| Latency p50 warm | ~700ms (cross-region) | ~0.92s (local + rerank) |
| Quality | 0.85 | 0.95+ trên relevant queries |
| Cost/tháng | ~$50 GCP credit | $0 |
Bài học rút ra
1. Latency cross-region là gốc rễ của perf issue. V1 → V2 cho thấy: replicate gần user > tune embedder/index. Nhưng V2 → V3 cho thấy: local thắng cloud cho data cá nhân, vì bạn không cần share infrastructure.
2. Cloud embedder API không phải free lunch. Voyage tiện về maintenance nhưng quality trade-off nặng cho mixed-language. Local bge-m3 (2.5GB RAM) miễn phí và chất lượng cao hơn rõ rệt.
3. Hybrid retrieval (dense + sparse) + cross-encoder rerank là step-change. Pure dense retrieval bị cap ở ~0.85 score. Hybrid + rerank đạt 0.95+. Cost: thêm ~300-500ms latency, nhưng vẫn dưới 1s warm.
4. Single-daemon design quan trọng khi có nhiều client. Nếu mỗi client load model riêng (Claude Desktop + ingest hook + iOS) → 7.5GB RAM. HTTP daemon shared → 2.5GB. Mac M2 Max 64GB không sweat, nhưng đây là pattern đáng học cho hardware nhỏ hơn.
5. Filesystem-first canonical = sleep-well rule. ADB là derived index. Sync 1-way: filesystem → ADB. Mỗi lần đổi embedder/schema (3 lần trong 4 phiên bản), tôi rebuild được index từ filesystem mà không sợ mất data.
6. Coverage > sophistication trong giai đoạn đầu. V1 chỉ index .md đã giải quyết 80% query. Office/OCR/VLM/structured action items là V4 — sau khi text baseline stable. Đừng phase 4 trước phase 1.
7. Tôi build cho chính tôi. “M3 daily-driver criterion” của tôi là: dùng tự nhiên hằng ngày 2 tuần liên tiếp không pivot stack. Hiện tại đã 1-2 tuần. Đến mốc đó là M3 done.
Stack tóm tắt (V4)
MacBook Pro M2 Max (12 cores, 64GB RAM)
├── HTTP daemon :8080 ──┐
│ ├── bge-m3 1024d hybrid (dense + sparse + ColBERT)
│ ├── bge-reranker-v2-m3 cross-encoder
│ ├── RRF fusion + dynamic rerank pool
│ └── Postgres 16 + pgvector (HNSW iterative_scan)
│
├── Cloudflare tunnel ──→ mcp.vuihoc.ai
│ └── (mobile/web Claude clients)
│
└── Filesystem canonical ~/Documents/KB/
├── kb-ingest-file.py hook (auto-tag từ folder)
├── extract_office.py (PDF/docx/xlsx/pptx)
├── extract_image_ocr.py (macOS Vision)
└── extract_image_vlm.py (Haiku fallback ~$0.0005/image)
Next
- TOTP 2FA cho OAuth (single password chưa đủ cho threat model serious).
- Reranker eval: bench bge-reranker-v2-m3 vs Cohere rerank-3 trên KB thật.
- Foundation cho 6 project khác trong portfolio (recipe-extractor, research-agent, support-bot, meeting-ai, finance-advisor, health-coach) — share cùng RAG daemon, mỗi project chỉ thêm domain tools.
Nếu bạn cũng đang build personal RAG, tôi mạnh dạn khuyên: bắt đầu local đi. Cloud chỉ cần khi bạn share data với người khác.