Wiki-RAG Guide
คู่มือสร้างฐานความรู้ส่วนตัวหรือของทีมที่ AI agent "ค้น" ได้ผ่าน MCP — เก็บความรู้เป็นไฟล์ Markdown, แบ่งเป็นชิ้น, แปลงเป็น vector, เก็บใน PostgreSQL แล้วให้ agent ดึงมาเฉพาะชิ้นที่ตรงคำถาม เขียนให้ทั้งคนและ AI agent อ่านแล้วสร้างตามได้
wiki-rag คืออะไร และแก้ปัญหาอะไร
wiki-rag คือฐานความรู้ที่ประกอบด้วย 3 ส่วน: (1) wiki — โฟลเดอร์ไฟล์ Markdown ที่คนและ agent ช่วยกันเขียน (2) RAG index — ไฟล์เหล่านั้นถูกตัดเป็นชิ้นเล็ก แปลงเป็น embedding vector แล้วเก็บในฐานข้อมูล (3) MCP server — เครื่องมือ search_knowledge ที่ AI เรียกใช้ได้ทันทีโดยไม่ต้องเขียน integration แยกต่อ agent แต่ละตัว
ปัญหาที่เจอถ้าไม่มี
- Agent ลืมทุกครั้งที่เริ่ม session ใหม่ — ข้อเท็จจริงที่เพิ่งค้นพบ (ที่อยู่ service, วิธีแก้บั๊ก, ข้อตกลงของทีม) หายไปพร้อม context
- โยนโน้ตทั้งไฟล์เข้า context เปลืองมาก — โน้ตหลายร้อยหน้ารวมกันหลาย MB ไม่พอดี context และทุกบรรทัดที่ไม่เกี่ยวคือ token ที่จ่ายเปล่า
- Keyword search ไม่เข้าใจความหมาย — ถามว่า "วิธีโพสต์ขึ้นเฟส" แต่โน้ตเขียนว่า "publish to feed" grep ไม่เจอ ส่วน semantic search เจอ
- ความรู้กระจัดกระจายต่อโปรเจกต์/ต่อ agent — แต่ละเครื่องมีโน้ตของตัวเอง ไม่มีที่กลางให้ทุกตัวอ่านและเขียนร่วมกัน
วิธีแก้ในหนึ่งภาพ
ก่อน
Agent อ่านไฟล์ notes.md ทั้งไฟล์ (~26 KB ต่อหน้า) หลายหน้า เพื่อหาประโยคเดียว context บวม ตอบช้า ลืมเมื่อจบ session
หลัง
Agent เรียก search_knowledge("คำถาม") ได้เฉพาะชิ้นที่ตรง รวมประมาณ ~5 KB พร้อมชื่อไฟล์ต้นทาง แล้วค่อยเปิดไฟล์เต็มเฉพาะเมื่อจำเป็น
ผลประโยชน์ และข้อเสีย (พูดตรงๆ)
ข้อดี
- ประหยัด token — ส่งเข้า context เฉพาะ chunk ที่เกี่ยวข้อง (ราว 5 KB ต่อการค้น) แทนการอ่านทั้งไฟล์ ยิ่งคลังโตยิ่งคุ้ม
- ความจำร่วมข้าม agent/โปรเจกต์ — ทุก agent ที่ต่อ MCP เดียวกันเห็นความรู้ชุดเดียวกัน และเขียนกลับได้
- เป็นส่วนตัว / self-hosted — ข้อมูลอยู่ในเครื่องเราเอง embedding รันในเครื่องได้ ไม่ต้องส่งโน้ตไปบริการภายนอก
- หลายภาษารวมไทย — เลือก embedding model ที่รองรับไทยจริง และวัดผลด้วยคำถามไทยของเราเอง
- คนอ่านได้ด้วย — ต้นทางเป็น Markdown ธรรมดา เปิดใน editor ไหนก็ได้ มี Git history ได้
- ใช้ DB ที่มีอยู่ — pgvector เป็นแค่ extension ของ PostgreSQL ไม่ต้องดูแล vector DB ตัวใหม่
ข้อเสีย
- มี infra ต้องดูแล — PostgreSQL, embedding service, MCP server, ไฟล์ env, การ restart เมื่อ service ใดล่ม ระบบค้นก็ล่มด้วย
- คุณภาพ embedding มีเพดาน — จัดอันดับผิดได้ โดยเฉพาะคำถามกำกวมหรือหน้าที่คล้ายกันมาก (เราเจอเคสหน้าผิดติด top-2)
- ข้อมูลเก่าถ้าลืม re-ingest — แก้ไฟล์แล้ว index ไม่ตาม agent จะตอบจากของเก่าอย่างมั่นใจ
- Chunking พลาดง่าย — ตัดกลางตาราง/โค้ด หรือ chunk เล็กเกินจนไม่มีบริบท ผลค้นแย่ทั้งที่ model ดี
- ความปลอดภัย — ความรู้ที่เปิดให้ค้นผ่านเครือข่ายคือข้อมูลรั่วได้ ต้องมี auth, จำกัดเครือข่าย และห้ามเก็บ secret ลง vault
- เปลี่ยน model = embed ใหม่หมด — vector จากคนละ model เทียบกันไม่ได้ คลังหลักพัน chunk อาจใช้เวลาหลายสิบนาที
คุ้มเมื่อโน้ตรวมเกินกว่าจะใส่ context ได้ และมี agent หลายตัว/หลาย session ที่ต้องใช้ความรู้ซ้ำ ถ้าคลังมีไม่กี่หน้า การให้ agent อ่านไฟล์ตรงๆ หรือ grep อาจเพียงพอและง่ายกว่ามาก
สถาปัตยกรรม
ระบบแยกเป็นสองฝั่งที่ทำงานคนละเวลา: ฝั่ง ingest (รันเมื่อโน้ตเปลี่ยน) และฝั่ง query (รันทุกครั้งที่ agent ถาม) ทั้งสองฝั่งใช้ embedding model ตัวเดียวกันและ PostgreSQL ตัวเดียวกัน
ตาราง component
| ส่วน | ทำอะไร | ทางเลือกทดแทน |
|---|---|---|
| Vault | ไฟล์ .md + frontmatter + [[wikilink]] + หน้า index เป็นแหล่งความจริง | โฟลเดอร์ Git, Obsidian, ไฟล์ธรรมดา |
| ingest.py | parse → chunk → embed → upsert เฉพาะไฟล์ที่ mtime เปลี่ยน | สคริปต์ใดก็ได้ที่ทำ 4 ขั้นนี้ |
| Embedding service | รับ text คืน vector คงที่ 768 มิติ มี prefix แยก query/document | Ollama, llama.cpp, sentence-transformers, API ภายนอก |
| PostgreSQL + pgvector | เก็บเอกสาร, chunk, vector และทำ cosine search ด้วย HNSW | SQLite + sqlite-vec สำหรับคลังเล็ก |
| MCP server | เปิด tool ให้ agent: ค้น, อ่านเอกสาร, list, reindex | stdio (เครื่องเดียว) หรือ HTTP (ใช้ร่วมกัน) |
ขั้นตอนสร้างทีละขั้น
โค้ดด้านล่างเป็น sketch ที่ย่อจากระบบจริง ใช้ได้เป็นโครงเริ่มต้น แทนค่า <...> ด้วยค่าของคุณเอง และเก็บ secret ใน environment variable เท่านั้น ห้ามใส่ในโค้ดหรือ vault
4.1 โครงสร้าง vault และ frontmatter
โครงสร้างโฟลเดอร์ที่ใช้ได้ผลคือแยกตามชนิดความรู้ ไม่ใช่ตามเวลา และมีหน้า 00-INDEX.md เป็นสารบัญให้ทั้งคนและ agent เริ่มจากตรงนี้
/path/to/vault/
├── 00-INDEX.md # สารบัญ: ทุกหน้าต้องมีแถวในนี้
├── entities/ # ของจริง: server, service, โปรเจกต์, บัญชี
├── concepts/ # แพทเทิร์น / สถาปัตยกรรม
├── decisions/ # ADR: ตัดสินใจอะไร เพราะอะไร (YYYY-MM-DD--adr-NNN--title.md)
├── procedures/ # runbook ทีละขั้น
├── references/ # snippet / คำสั่งที่ใช้บ่อย
├── logs/ # บันทึกตามวัน (append-only)
└── sources/ # ข้อมูลดิบ ห้ามแก้---
created: 2026-01-15
updated: 2026-03-02 # bump ทุกครั้งที่แก้เนื้อหา
tags: [postgres, backup]
category: procedure # entity|concept|decision|procedure|source|log|reference
related: [[db-server]], [[backup-policy]] # อย่างน้อย 1 ลิงก์
status: active # draft|active|stale|archived
---
# หัวข้อหน้า
## ขั้นตอน
...เหตุผล: หัวข้อ ## คือเส้นแบ่ง chunk (ย่อหน้าละเรื่องจึงค้นแม่น), updated ช่วยหาหน้าตกยุค, related กันหน้ากำพร้า, ส่วน status: stale ให้ agent รู้ว่าอย่าเชื่อหน้านั้นโดยไม่ตรวจ
4.2 Database schema
ต้องสร้าง database ด้วย encoding UTF-8 ตั้งแต่แรก (ถ้าเป็น SQL_ASCII ภาษาไทยจะเพี้ยน) ตาราง vector แยกจาก chunks เพื่อให้เปลี่ยน model ได้โดยไม่แตะข้อมูลเดิม
CREATE DATABASE wiki_rag WITH ENCODING 'UTF8'
LC_COLLATE 'C.UTF-8' LC_CTYPE 'C.UTF-8' TEMPLATE template0;
\c wiki_rag
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id serial PRIMARY KEY,
rel_path text NOT NULL UNIQUE, -- path สัมพัทธ์ใน vault = key
title text,
content text, -- ทั้งไฟล์ ไว้ให้ get_document
doc_meta jsonb, -- frontmatter
ingested_at timestamptz DEFAULT now()
);
CREATE TABLE chunks (
id serial PRIMARY KEY,
document_id integer NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
chunk_index integer NOT NULL,
heading text,
content text,
UNIQUE (document_id, chunk_index)
);
-- 1 ตาราง vector ต่อ 1 embedding model (ดู 4.8 การสลับ model)
CREATE TABLE chunk_embeddings_g2 (
chunk_id integer PRIMARY KEY REFERENCES chunks(id) ON DELETE CASCADE,
embedding vector(768) NOT NULL
);
CREATE INDEX chunk_emb_g2_hnsw ON chunk_embeddings_g2
USING hnsw (embedding vector_cosine_ops);
-- ไว้ให้ ingest ข้ามไฟล์ที่ไม่เปลี่ยน
CREATE TABLE ingest_state (
rel_path text PRIMARY KEY,
mtime_ns bigint NOT NULL,
ingested_at timestamptz DEFAULT now()
);HNSW/IVFFlat ของ pgvector รุ่นที่ใช้ รองรับ vector ไม่เกิน 2000 มิติ model 4096 มิติจึงทำได้แค่ exact search (ช้าลงเมื่อคลังโต) ข้อนี้เป็นเหตุผลหนึ่งที่ควรเลือก model 768–1024 มิติ
4.3 เลือกและรัน embedding model
embedding model คือส่วนที่กำหนดคุณภาพการค้นมากที่สุด เกณฑ์ที่ใช้เลือก: รองรับภาษาของเรา, มิติไม่เกิน 2000, รันในเครื่องได้, context พอสำหรับ chunk ~1800 ตัวอักษร
model แรกที่เราใช้ (mxbai-embed-large) ตาบอดภาษาไทย — ข้อความไทยทุกประโยคถูกแปลงเป็น vector เกือบเหมือนกันหมด ผลค้นจึงสุ่ม โดยที่ไม่มี error ใดๆ ก่อนเลือก model ให้ embed ประโยคไทย 5–10 ประโยคที่ความหมายต่างกันแล้วดูว่า cosine similarity ระหว่างกันต่างกันจริงหรือไม่
กติกาที่ต้องรักษา
- Prefix แยก query/document — model อย่าง embeddinggemma ถูกเทรนให้ฝั่งเอกสารใช้รูปแบบ
title: <ชื่อ> | text: <เนื้อหา>และฝั่งคำถามใช้ prefix แบบ query (หรือส่งinput_typeตามที่ service กำหนด) ถ้าสลับหรือลืม คะแนนจะตกเงียบๆ อ่านคู่มือของ model ที่เลือก - มิติต้องคงที่ — ตาราง
vector(768)ต้องตรงกับ model ทุกครั้ง ตรวจจำนวนมิติใน response ก่อน insert (ถ้าไม่ตรงให้ throw ไม่ใช่เก็บไปก่อน) - ห้ามผสม model — vector จากคนละ model อยู่คนละ space เทียบกันแล้วได้ตัวเลขไร้ความหมาย
- Matryoshka — model บางตัวตัดมิติให้สั้นลงได้ (768 → 512 → 256) เพื่อประหยัดที่เก็บ แต่ต้องวัดผลก่อน (ดูตารางด้านล่าง)
service ฝั่ง embedding ขอแค่ HTTP endpoint ที่รับรายการข้อความแล้วคืน vector ตามลำดับ ตัวอย่างสัญญาที่ ingest และ MCP server ในคู่มือนี้คาดหวัง:
POST http://<embed-host>:<port>/v1/embeddings
{ "input": ["title: A | text: ...", "..."], "input_type": "document" } # หรือ "query"
200 OK
{ "data": [ {"index": 0, "embedding": [0.012, -0.044, ... 768 ค่า]}, ... ] }การรัน: ถ้าเครื่องมี GPU ว่างก็ใช้ได้ แต่ model ขนาดนี้รัน CPU อย่างเดียว ได้ (เราตั้ง process เป็น idle priority เพื่อไม่แย่งงานอื่น) ราว ~0.2 วินาทีต่อคำถาม และราว ~1 วินาทีต่อ chunk ตอน ingest — re-embed คลัง 1,475 chunk ใช้ประมาณ 26 นาที ซึ่งยอมรับได้เมื่อ ingest เป็นแบบ incremental
วิธีที่เราประเมิน (ใช้ซ้ำได้)
- สุ่ม chunk จริงจากคลัง ให้ LLM สร้างคำถามที่ chunk นั้นตอบได้ (เราใช้ 45 คำถาม: ไทย 23 / อังกฤษ 22 บน 1,475 chunk)
- embed คำถามด้วยแต่ละ model แล้วดูว่า chunk ต้นทางอยู่อันดับเท่าไหร่
- รายงาน MRR และ Recall@k แยกตามภาษา
| Model | MRR | R@1 | R@5 | R@10 |
|---|---|---|---|---|
| bge-m3 · 1024-d | 0.816 | 0.76 | 0.91 | 0.91 |
| embeddinggemma-2 · 768-d | 0.872 | 0.80 | 0.96 | 0.98 |
| embeddinggemma-2 · 512-d | 0.880 | 0.82 | 0.96 | 1.00 |
| embeddinggemma-2 · 256-d | 0.806 | 0.71 | 0.91 | 0.93 |
เฉพาะคำถามภาษาไทย MRR เพิ่มจาก 0.813 (bge-m3) เป็น 0.910 (embeddinggemma-2)
ชุดทดสอบเล็ก ความต่างระหว่าง model เท่ากับราว 2–3 คำถาม และ spot-check พบว่า embeddinggemma-2 เคยจัดหน้าผิดไว้อันดับ 2 ในคำถามที่ bge-m3 ตอบถูก อย่าสรุปว่า model หนึ่งดีกว่าทุกกรณี — ให้ทำชุดทดสอบจากคลังของคุณเอง และเลือกตัวที่ผ่านชุดนั้น
4.4 ingest script
หลักการ: อ่านไฟล์ที่ mtime เปลี่ยน → ตัดตาม heading ##/### → ถ้า section ยาวเกิน ~1800 ตัวอักษรให้ตัดซ้อน 120 ตัวอักษร → embed เป็น batch → upsert ใน transaction เดียวต่อไฟล์
import os, re, sys, json, argparse, requests, psycopg
from pgvector.psycopg import register_vector
MAX, OVERLAP, DIM = 1800, 120, 768
VAULT = os.environ["WIKI_SOURCE_DIR"] # /path/to/vault
DSN = os.environ["WIKI_DATABASE_URL"] # postgresql://user:***@<db-host>:5432/wiki_rag
EMBED = os.environ["WIKI_EMBED_URL"] # http://<embed-host>:<port>
SKIP_DIRS = {".git", ".obsidian", "node_modules"}
def split_sections(body):
"""คืน [(heading, text)] ตามหัวข้อ ## / ###"""
parts, head, buf = [], "", []
for line in body.splitlines():
if re.match(r"^#{2,3} ", line):
if buf: parts.append((head, "\n".join(buf).strip()))
head, buf = line.lstrip("# ").strip(), []
else:
buf.append(line)
if buf: parts.append((head, "\n".join(buf).strip()))
return [p for p in parts if p[1]]
def chunk(text):
if len(text) <= MAX: return [text]
step = MAX - OVERLAP
return [text[i:i+MAX] for i in range(0, len(text), step)]
def embed_docs(title_text_pairs):
inputs = [f"title: {t or 'none'} | text: {x}" for t, x in title_text_pairs]
r = requests.post(f"{EMBED}/v1/embeddings",
json={"input": inputs, "input_type": "document"}, timeout=900)
r.raise_for_status()
vecs = [d["embedding"] for d in sorted(r.json()["data"], key=lambda d: d["index"])]
assert len(vecs) == len(inputs) and all(len(v) == DIM for v in vecs), "bad embedding shape"
return vecs
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--only"); ap.add_argument("--force", action="store_true")
args = ap.parse_args()
with psycopg.connect(DSN) as conn:
register_vector(conn); cur = conn.cursor()
cur.execute("SELECT rel_path, mtime_ns FROM ingest_state")
seen = dict(cur.fetchall())
for root, dirs, files in os.walk(VAULT):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
for f in files:
if not f.endswith(".md") or f.startswith("._"): continue
path = os.path.join(root, f); rel = os.path.relpath(path, VAULT)
if args.only and args.only not in rel: continue
mt = os.stat(path).st_mtime_ns
if not args.force and seen.get(rel) == mt: continue
text = open(path, encoding="utf-8").read()
title = (re.search(r"^# (.+)$", text, re.M) or [None, f])[1]
rows = [(h, c) for h, s in split_sections(text) for c in chunk(s)]
vecs = embed_docs([(title, c) for _, c in rows]) if rows else []
cur.execute("""INSERT INTO documents (rel_path,title,content) VALUES (%s,%s,%s)
ON CONFLICT (rel_path) DO UPDATE SET title=EXCLUDED.title,
content=EXCLUDED.content, ingested_at=now() RETURNING id""", (rel, title, text))
doc_id = cur.fetchone()[0]
cur.execute("DELETE FROM chunks WHERE document_id=%s", (doc_id,)) # cascade ลบ vector
for i, ((h, c), v) in enumerate(zip(rows, vecs)):
cur.execute("INSERT INTO chunks (document_id,chunk_index,heading,content) "
"VALUES (%s,%s,%s,%s) RETURNING id", (doc_id, i, h, c))
cur.execute("INSERT INTO chunk_embeddings_g2 VALUES (%s,%s)",
(cur.fetchone()[0], v))
cur.execute("""INSERT INTO ingest_state (rel_path,mtime_ns) VALUES (%s,%s)
ON CONFLICT (rel_path) DO UPDATE SET mtime_ns=EXCLUDED.mtime_ns""", (rel, mt))
conn.commit(); print("ingested", rel, len(rows), "chunks")
if __name__ == "__main__": main()ของจริงที่ใช้งานเพิ่ม: parse YAML frontmatter ลง doc_meta, ลบเอกสารที่ไฟล์หายไปแล้ว, ตัดตามย่อหน้าก่อนตัดตามจำนวนตัวอักษร, และใส่ embed เป็น batch ละ ~16 chunk
4.5 MCP server (FastMCP)
ตัวอย่างขั้นต่ำที่ใช้ได้จริง: เปิดผ่าน HTTP เพื่อให้หลายเครื่องใช้ร่วมกัน และบังคับ bearer token ถ้าใช้คนเดียวบนเครื่องเดียว ใช้ transport="stdio" ได้และไม่ต้องมี auth
import os, json, subprocess, requests, psycopg
from fastmcp import FastMCP
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
TOKEN = os.environ["WIKI_MCP_TOKEN"] # สุ่มยาวๆ เก็บใน env เท่านั้น
auth = StaticTokenVerifier(tokens={TOKEN: {"client_id": "agent", "scopes": []}})
mcp = FastMCP("wiki-rag", auth=auth)
DSN, EMBED = os.environ["WIKI_DATABASE_URL"], os.environ["WIKI_EMBED_URL"]
def embed_query(text):
r = requests.post(f"{EMBED}/v1/embeddings",
json={"input": [text], "input_type": "query"}, timeout=30)
r.raise_for_status()
return r.json()["data"][0]["embedding"]
@mcp.tool()
def search_knowledge(query: str, limit: int = 5, min_score: float = 0.0) -> str:
"""Semantic search. Returns the most relevant chunks with source path and score."""
limit = max(1, min(int(limit), 20))
vec = str(embed_query(query)) # '[0.1,0.2,...]' → ::vector
with psycopg.connect(DSN) as conn:
rows = conn.execute("""
SELECT c.content, c.heading, d.rel_path, d.title,
round((1 - (g.embedding <=> %s::vector))::numeric, 4) AS sim
FROM chunk_embeddings_g2 g
JOIN chunks c ON c.id = g.chunk_id
JOIN documents d ON d.id = c.document_id
WHERE 1 - (g.embedding <=> %s::vector) >= %s
ORDER BY g.embedding <=> %s::vector
LIMIT %s""", (vec, vec, min_score, vec, limit)).fetchall()
return json.dumps({"query": query, "results": [
{"score": float(r[4]), "rel_path": r[2], "title": r[3],
"heading": r[1], "content": r[0]} for r in rows]}, ensure_ascii=False)
@mcp.tool()
def get_document(rel_path: str) -> str:
"""Return one full document. Use only after search_knowledge points to it."""
with psycopg.connect(DSN) as conn:
row = conn.execute("SELECT title, content FROM documents WHERE rel_path=%s",
(rel_path,)).fetchone()
return json.dumps({"rel_path": rel_path, "title": row[0], "content": row[1]}
if row else {"error": "not found"}, ensure_ascii=False)
@mcp.tool()
def list_documents() -> str:
"""List all indexed documents (path + title)."""
with psycopg.connect(DSN) as conn:
rows = conn.execute("SELECT rel_path, title FROM documents ORDER BY rel_path").fetchall()
return json.dumps([{"rel_path": a, "title": b} for a, b in rows], ensure_ascii=False)
@mcp.tool()
def reindex(only: str = "") -> str:
"""Re-ingest changed files (optionally only paths containing `only`)."""
cmd = ["python", "ingest.py"] + (["--only", only] if only else [])
p = subprocess.run(cmd, capture_output=True, text=True, timeout=1800)
return (p.stdout + p.stderr)[-2000:]
if __name__ == "__main__":
mcp.run(transport="http", host="0.0.0.0", port=8000, path="/mcp")ผูก host กับ interface ภายในหรือวางหลัง reverse proxy/TLS, จำกัดไฟร์วอลล์ให้เฉพาะ client ที่ต้องใช้, ใช้ DB user แบบสิทธิ์น้อย (ฝั่งอ่านใช้ user read-only) และพิจารณาว่า reindex ควรเปิดให้ agent ทุกตัวหรือไม่ เพราะมันรันคำสั่งบนเครื่อง server
ของจริงที่ใช้งานเพิ่ม: connection pool (เปิด connection ใหม่ทุก request ช้ากว่ามาก), resource wiki://stats สำหรับดูจำนวนเอกสาร/chunk, และ search provider ที่สลับได้ด้วย env var (ดู 4.8)
4.6 รันเป็น service และต่อเข้า MCP client
[Unit]
Description=wiki-rag MCP server
After=network-online.target
[Service]
WorkingDirectory=/opt/wiki-rag
EnvironmentFile=/etc/wiki-rag.env # chmod 600: WIKI_MCP_TOKEN, WIKI_DATABASE_URL, WIKI_EMBED_URL
ExecStart=/opt/wiki-rag/venv/bin/python mcp_server.py
Restart=on-failure
User=wikirag
[Install]
WantedBy=multi-user.targetต่อ Claude Code (เก็บ token ไว้ใน env var ของ shell อย่า commit ลง repo):
export WIKI_MCP_TOKEN='...' # ค่าจริงเก็บนอก repo
claude mcp add --transport http wiki-rag http://<mcp-host>:8000/mcp \
--header "Authorization: Bearer $WIKI_MCP_TOKEN"
claude mcp list # ต้องเห็น wiki-rag: connectedclient อื่นที่รองรับ MCP over HTTP (Cursor, opencode, Cline ฯลฯ) ใช้ค่าเดียวกัน: URL ของ endpoint + header Authorization: Bearer ... แล้วเรียก tool ชื่อเดียวกัน
4.7 workflow การ re-ingest
- แก้/เพิ่มโน้ตใน vault พร้อม frontmatter ครบ และเพิ่มแถวใน
00-INDEX.mdถ้าเป็นหน้าใหม่ - Re-ingest เฉพาะไฟล์ที่แตะ —
python ingest.py --only entities/db-server.md(หรือให้ agent เรียก toolreindex) - ตรวจ — ค้นประโยคที่เพิ่งเขียนด้วย
search_knowledgeต้องเจอหน้านั้นใน top 3 - งานเป็นรอบ — ตั้ง cron/timer รัน
python ingest.py(mtime cache ทำให้ข้ามไฟล์ที่ไม่เปลี่ยน) กันลืม
./venv/bin/python ingest.py # incremental: เฉพาะไฟล์ที่ mtime เปลี่ยน
./venv/bin/python ingest.py --only procedures/ # เฉพาะ path ที่มีข้อความนี้
./venv/bin/python ingest.py --force # ทำใหม่ทั้งหมด (ต้องใช้เมื่อเปลี่ยน model/chunking)4.8 สลับ embedding model อย่างปลอดภัย
การสลับ model คือการ migrate ข้อมูล ไม่ใช่การเปลี่ยนค่า config ลำดับที่ปลอดภัย:
- สร้างตาราง vector ใหม่ขนานกับของเดิม (
chunk_embeddings_<model>มิติตาม model ใหม่ + HNSW) ไม่แตะตารางเก่า - Backfill — embed ทุก chunk ที่มีอยู่ด้วย model ใหม่ลงตารางใหม่ (ใช้เวลาตามจำนวน chunk; ของเราราว 26 นาทีสำหรับ 1,475 chunk)
- วัดผล ด้วยชุดคำถามของคุณเองทั้ง model เก่า-ใหม่ และ spot-check คำถามที่ผู้ใช้ถามบ่อย
- สลับด้วย provider switch — ให้ MCP server เลือกตารางจาก env var เช่น
WIKI_SEARCH_PROVIDER=new|oldแล้ว restart service เท่านั้น ไม่ต้องแก้โค้ด - เก็บทางถอย — เก็บตารางเก่าไว้จนมั่นใจ (ตั้ง ingest ให้ embed ลงตาราง active เท่านั้นได้ แต่ต้องรู้ว่าถ้าถอยแล้วตารางเก่าไม่ทันโน้ตใหม่ ต้อง
--forcere-embed)
PROVIDERS = {
"new": {"table": "chunk_embeddings_g2", "dim": 768, "embed": embed_query_new},
"old": {"table": "chunks_embedding_old", "dim": 1024, "embed": embed_query_old},
}
P = PROVIDERS[os.environ.get("WIKI_SEARCH_PROVIDER", "new")]
# SQL ใน search_knowledge ใช้ P["table"]; rollback = ตั้ง env กลับ + restartตัวอย่างการใช้งานกับ AI
5.1 คำสั่งสอน agent (ใส่ใน CLAUDE.md / system prompt)
## Project knowledge (ทำตามลำดับ)
1. ก่อนอ่านไฟล์หรือถามผู้ใช้ ให้เรียก MCP tool `search_knowledge("<คำถาม>")` ก่อนเสมอ
2. ถ้าผลมี score < 0.5 หรือไม่ตรงคำถาม ให้ลองเปลี่ยนคำค้น 1-2 ครั้ง แล้วค่อยบอกว่าไม่พบ
3. เรียก `get_document(rel_path)` เฉพาะเมื่อ chunk ที่ได้ไม่พอ (ไฟล์เต็มกินหลาย KB)
4. ตอบพร้อมอ้างอิง rel_path ของหน้าที่ใช้
5. ถ้าข้อเท็จจริงมีอยู่ใน wiki แล้ว ตอบจาก wiki ห้ามไล่อ่านโค้ด/log ใหม่
6. เมื่อค้นพบความรู้ใหม่ ให้เขียนลง vault แล้ว `reindex(only="<path>")` (ดูหัวข้อ 5.3)
7. ห้ามเขียน secret (password/API key/token) ลง vault5.2 ตัวอย่าง tool call และผลลัพธ์
{
"name": "search_knowledge",
"arguments": { "query": "วิธีสำรองฐานข้อมูลก่อน migrate", "limit": 3 }
}{
"query": "วิธีสำรองฐานข้อมูลก่อน migrate",
"results": [
{
"score": 0.7412,
"rel_path": "procedures/db-backup-before-migrate.md",
"title": "สำรอง DB ก่อน migrate",
"heading": "ขั้นตอน",
"content": "1. รัน pg_dump -Fc ... 2. ตรวจขนาดไฟล์ไม่เป็นศูนย์ 3. ..."
},
{
"score": 0.6120,
"rel_path": "decisions/2026-03-01--adr-002--backup-policy.md",
"title": "ADR-002: นโยบายสำรองข้อมูล",
"heading": "การตัดสินใจ",
"content": "สำรองทุกคืน เก็บ 14 วัน ..."
}
]
}สังเกตว่า response มีทั้ง rel_path (ไว้อ้างอิง/เปิดไฟล์เต็ม) และ score (ไว้ให้ agent ตัดสินว่าผลน่าเชื่อถือแค่ไหน) ค่า score ใช้เทียบกันภายใน model เดียวกันเท่านั้น
5.3 workflow: agent เรียนรู้แล้วเขียนกลับ
- Agent ค้นพบข้อเท็จจริงใหม่ระหว่างทำงาน (เช่น วิธีแก้ error ที่ต้องลองหลายรอบ)
- เรียก
search_knowledgeเช็คก่อนว่ามีหน้าเดิมหรือไม่ — ถ้ามี ให้เพิ่ม section ใหม่ (append) ไม่เขียนทับ ถ้าข้อมูลขัดกัน ให้ใส่หมายเหตุContradictionแทนที่จะลบของเก่าเงียบๆ - เขียนไฟล์ใน vault ตามโครงสร้างและ frontmatter (4.1) แล้ว bump
updated:และเพิ่มแถวใน00-INDEX.mdถ้าเป็นหน้าใหม่ - เรียก
reindex(only="procedures/fix-xyz.md") - ตรวจ:
search_knowledge("<ประโยคที่เพิ่งเขียน>")ต้องได้หน้านั้นกลับมา — ถ้าไม่ได้ แปลว่า ingest พังหรือ chunk ไม่ดี
เข้า vault: ข้อเท็จจริงที่ใช้ข้ามโปรเจกต์ ขั้นตอนที่พิสูจน์แล้ว การตัดสินใจพร้อมเหตุผล ไม่เข้า: secret ทุกชนิด, ข้อมูลลูกค้า, ข้อมูลที่เปลี่ยนทุกนาที (ให้ดึงสดแทน), และสถานะชั่วคราวของงานที่กำลังทำ
สำหรับ AI agent: ถ้าคุณกำลังจะสร้างระบบนี้
If you are an AI agent replicating this. ทำตามลำดับ ห้ามข้ามขั้น ทุกขั้นมีเงื่อนไขผ่านที่ตรวจได้ด้วยคำสั่ง ถ้าไม่ผ่านให้หยุดแก้ขั้นนั้นก่อน ห้ามเดาค่าที่เป็นของผู้ใช้ (host, รหัสผ่าน, path) — ถามผู้ใช้ แล้วเก็บ secret ใน environment variable เท่านั้น และห้ามพิมพ์ค่า secret ลง output/log
- 1เตรียม PostgreSQL 16+ พร้อม pgvector และสร้าง database แบบ UTF-8
ผ่านเมื่อ:
SELECT extname FROM pg_extension WHERE extname='vector'ได้ 1 แถว และSHOW server_encoding= UTF8 - 2เลือก embedding model แล้วทดสอบกับภาษาของผู้ใช้ก่อน (embed ประโยคต่างความหมาย 5+ ประโยค) ผ่านเมื่อ: cosine similarity ระหว่างประโยคต่างความหมายต่างกันชัดเจน (ไม่ใกล้ 1.0 ทั้งหมด) และจำนวนมิติใน response คงที่เท่ากันทุกครั้ง
- 3รัน embedding service และบันทึก prefix/
input_typeของ query กับ document จากเอกสารของ model ผ่านเมื่อ: POST/v1/embeddingsได้ vector ความยาว = มิติของตาราง และ query กับ document ใช้ prefix คนละแบบตามที่ model กำหนด - 4รัน
schema.sql(4.2) โดยให้มิติในvector(N)ตรงกับ model ผ่านเมื่อ:\d chunk_embeddings_g2แสดงคอลัมน์ vector(N) และมี index แบบ hnsw - 5สร้าง vault ตามโครงสร้างและ frontmatter (4.1) พร้อม
00-INDEX.mdและใส่โน้ตจริงอย่างน้อย 10 หน้า ผ่านเมื่อ: ทุกหน้ามี frontmatter ครบ 5 ฟิลด์ และไม่มีค่า secret อยู่ในไฟล์ใดเลย (grep หาpassword,api_key,BEGIN PRIVATE KEYได้ 0) - 6เขียนและรัน
ingest.py(4.4) ครั้งแรก ผ่านเมื่อ:SELECT count(*) FROM chunks> 0 และจำนวนแถวในchunk_embeddings_*= จำนวนแถวในchunks; รันซ้ำทันทีต้องข้ามทุกไฟล์ (mtime cache) - 7ทดสอบ search ตรงใน SQL ก่อนทำ MCP: embed คำถาม 5 ข้อ (ไทยอย่างน้อย 2) ที่รู้คำตอบ ผ่านเมื่อ: หน้าที่ถูกต้องอยู่ใน top 3 อย่างน้อย 4 ใน 5 ข้อ ถ้าไม่ถึง ให้ตรวจ prefix, chunking และ model ก่อน
- 8เขียน MCP server (4.5) ตั้ง bearer token จาก env และรันเป็น service
ผ่านเมื่อ: เรียกโดยไม่มี header ได้ 401; เรียกด้วย token ที่ถูกต้องผ่าน และ tool list มี
search_knowledge,get_document,list_documents,reindex - 9ต่อ MCP client (4.6) แล้วเรียก
search_knowledgeผ่าน client จริง ผ่านเมื่อ: client แสดงสถานะ connected และ response เป็น JSON ที่มีresults[].rel_pathและscore - 10เพิ่มคำสั่งสอน agent (5.1) ลงไฟล์ instruction ของ agent
ผ่านเมื่อ: ถามคำถามที่ตอบได้จาก vault แล้ว agent เรียก
search_knowledgeก่อนอ่านไฟล์ และอ้างอิง rel_path - 11ทดสอบวงจรเขียนกลับ (5.3): เขียนหน้าใหม่ →
reindex→ ค้นเจอ ผ่านเมื่อ: ค้นประโยคที่เพิ่งเขียนแล้วหน้าใหม่อยู่ใน top 3 โดยไม่ต้อง restart server - 12ตั้ง re-ingest อัตโนมัติ (cron/timer) และ backup ฐานข้อมูล
ผ่านเมื่อ: แก้ไฟล์ 1 ไฟล์ รอรอบถัดไป แล้วค้นเจอเนื้อหาใหม่;
pg_dumprestore ลงฐานทดสอบได้
กฎที่ห้ามพลาด
- ห้ามผสม vector จากคนละ model ในตารางเดียว และห้ามเปลี่ยน model โดยไม่ re-embed หรือสร้างตารางขนาน
- ห้ามบอกว่า "เสร็จ" จนกว่าจะรันเช็คของขั้นนั้นแล้วเห็นผลจริง — อย่าเชื่อว่าโค้ดถูกเพราะอ่านแล้วดูถูก
- ห้ามเปิด MCP endpoint สู่อินเทอร์เน็ตสาธารณะโดยไม่มี auth และไม่มี TLS
- ห้ามใส่ secret ลง vault, โค้ด, หรือ log; อ้างถึงด้วยชื่อ env var เท่านั้น
- ถ้าค่าที่ต้องใช้ขาด (host, DSN, token) ให้ถามผู้ใช้ ไม่ใช่สร้างค่าขึ้นเอง
บทเรียนและ gotchas จากระบบจริง
| เรื่อง | อาการ | วิธีกัน |
|---|---|---|
| Model ตาบอดภาษา | ข้อความไทยทุกประโยคได้ vector เกือบเหมือนกัน ผลค้นสุ่ม ไม่มี error | ทดสอบ model ด้วยประโยคภาษาจริงก่อนเลือก และมีชุดคำถามวัดผล (4.3) |
| ผสม model | เปลี่ยน model แล้ว vector เก่า/ใหม่ปนกัน คะแนนไร้ความหมาย | ตารางขนานต่อ model, --force เมื่อเปลี่ยน, ตรวจมิติก่อน insert |
| ลืม prefix | คุณภาพตกเล็กน้อยทุกคำถาม หาสาเหตุยาก | ห่อการสร้าง prefix ไว้ฟังก์ชันเดียว ทดสอบด้วยชุดคำถาม |
| Index ล้าหลัง | agent ตอบตามโน้ตเก่าอย่างมั่นใจ | reindex หลังเขียนทุกครั้ง + timer รายคืน + updated: ใน frontmatter |
| Endpoint ที่พึ่งพาเปลี่ยน | วันหนึ่ง proxy หน้า embedding เลิกรู้จัก model ทุก request ตอบ 400 ทำให้ทั้ง ingest และ search พัง | เรียก embedding service ตรงได้ผ่าน config; มี health check และ alert; รู้ว่าฝั่ง search ผูกกับ service นี้ |
| DB encoding | ภาษาไทยเพี้ยนใน database ที่สร้างจาก template เริ่มต้น | สร้างด้วย ENCODING 'UTF8' และ TEMPLATE template0 |
| Index ไม่รับมิติสูง | vector 4096 มิติสร้าง HNSW ไม่ได้ ต้อง exact scan | เลือก model ≤ 2000 มิติ หรือใช้ Matryoshka ตัดมิติ (หลังวัดผล) |
| Connection ใหม่ทุก request | search ช้าโดยไม่จำเป็น (handshake + auth ซ้ำ) | ใช้ connection pool ใน MCP server |
| Chunk ใหญ่/เล็กเกิน | ใหญ่ = ผลค้นเจือจาง เล็ก = ไม่มีบริบท ตารางและโค้ดถูกตัดกลาง | ตัดตาม heading ก่อน แล้วค่อยตามขนาด (~1800) + overlap (120) และปรับตามคลังของคุณ |
| Re-embed ช้า | เปลี่ยน model แล้วต้องรอหลายสิบนาที | รันตอน off-peak, ทำ backfill เป็น batch, ใช้ incremental ในงานประจำ |
| Eval sample เล็ก | ตัวเลข MRR ต่างกันแค่ 2–3 คำถาม แปลผลเกินจริง | เพิ่มคำถามจากการใช้งานจริงสะสมเรื่อยๆ และ spot-check คำถามสำคัญ |
| ไฟล์ขยะ | ไฟล์ ._*.md (macOS), .obsidian/, node_modules/ ถูก index | กำหนด exclude list ใน ingest |
| ค้นไม่เจอเพราะ score ต่ำ | agent ถอดใจเร็ว | สอน agent ให้ลองเปลี่ยนคำค้น และใช้ min_score เป็นตัวช่วย ไม่ใช่ตัวตัดสินตายตัว |
แนวทางต่อยอด
- Hybrid search: รวมผล vector กับ keyword (
pg_trgm/ full-text) แล้ว rerank — แลกกับความซับซ้อนที่เพิ่ม - Lint vault เป็นระยะ: หาหน้ากำพร้า ลิงก์เสีย หน้าที่
updatedเก่าเกิน 30 วัน และข้อมูลขัดกัน - model แบบ multimodal (เช่น embeddinggemma-2 รองรับภาพ/เสียงใน space เดียวกัน) เปิดทางให้ค้นรูปด้วยข้อความ ถ้าต้องการในอนาคต