wiki-rag guide
Self-hosted knowledge base for AI agents

Wiki-RAG Guide

คู่มือสร้างฐานความรู้ส่วนตัวหรือของทีมที่ AI agent "ค้น" ได้ผ่าน MCP — เก็บความรู้เป็นไฟล์ Markdown, แบ่งเป็นชิ้น, แปลงเป็น vector, เก็บใน PostgreSQL แล้วให้ agent ดึงมาเฉพาะชิ้นที่ตรงคำถาม เขียนให้ทั้งคนและ AI agent อ่านแล้วสร้างตามได้

Markdown vaultPostgreSQL 16 + pgvectorembeddinggemma-2 · 768-dFastMCPไทย + อังกฤษ
สารบัญ 1. คืออะไร / ปัญหาที่แก้2. ข้อดี และ ข้อเสีย3. สถาปัตยกรรม4. ขั้นตอนสร้าง5. ตัวอย่างการใช้กับ AI6. สำหรับ AI agent7. บทเรียน / gotchas
SECTION 1

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 พร้อมชื่อไฟล์ต้นทาง แล้วค่อยเปิดไฟล์เต็มเฉพาะเมื่อจำเป็น

SECTION 2

ผลประโยชน์ และข้อเสีย (พูดตรงๆ)

ข้อดี

  • ประหยัด 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 อาจเพียงพอและง่ายกว่ามาก

SECTION 3

สถาปัตยกรรม

ระบบแยกเป็นสองฝั่งที่ทำงานคนละเวลา: ฝั่ง ingest (รันเมื่อโน้ตเปลี่ยน) และฝั่ง query (รันทุกครั้งที่ agent ถาม) ทั้งสองฝั่งใช้ embedding model ตัวเดียวกันและ PostgreSQL ตัวเดียวกัน

สถาปัตยกรรม wiki-rag Vault ผ่าน ingest.py และ embedding service เข้า PostgreSQL pgvector; AI agent เรียก MCP server ซึ่ง embed คำถามแล้วค้นใน PostgreSQL INGEST (เมื่อโน้ตเปลี่ยน) QUERY (ทุกครั้งที่ agent ถาม) Markdown vault frontmatter · wikilinks หน้า index ingest.py chunk ตาม heading ~1800 overlap 120 · mtime cache Embedding service <embed-host> · CPU ได้ text → 768-d vector PostgreSQL + pgvector documents chunks chunk_embeddings (HNSW) <db-host>:5432 upsert AI agent / MCP client Claude Code · Cursor opencode · อื่นๆ MCP server (FastMCP) HTTP + bearer auth <mcp-host>:8000/mcp search_knowledge top-k chunks cosine search embed คำถาม (prefix แบบ query) ฝั่ง query + ingest ต้องใช้ model/มิติเดียวกัน agent เขียนโน้ตใหม่ลง vault → เรียก reindex(only=…) → ค้นเจอทันที
เส้นทึบ = ข้อมูลไหล · เส้นประ = การ embed คำถามด้วย embedding service ตัวเดียวกับตอน ingest

ตาราง component

ส่วนทำอะไรทางเลือกทดแทน
Vaultไฟล์ .md + frontmatter + [[wikilink]] + หน้า index เป็นแหล่งความจริงโฟลเดอร์ Git, Obsidian, ไฟล์ธรรมดา
ingest.pyparse → chunk → embed → upsert เฉพาะไฟล์ที่ mtime เปลี่ยนสคริปต์ใดก็ได้ที่ทำ 4 ขั้นนี้
Embedding serviceรับ text คืน vector คงที่ 768 มิติ มี prefix แยก query/documentOllama, llama.cpp, sentence-transformers, API ภายนอก
PostgreSQL + pgvectorเก็บเอกสาร, chunk, vector และทำ cosine search ด้วย HNSWSQLite + sqlite-vec สำหรับคลังเล็ก
MCP serverเปิด tool ให้ agent: ค้น, อ่านเอกสาร, list, reindexstdio (เครื่องเดียว) หรือ HTTP (ใช้ร่วมกัน)
SECTION 4

ขั้นตอนสร้างทีละขั้น

โค้ดด้านล่างเป็น sketch ที่ย่อจากระบบจริง ใช้ได้เป็นโครงเริ่มต้น แทนค่า <...> ด้วยค่าของคุณเอง และเก็บ secret ใน environment variable เท่านั้น ห้ามใส่ในโค้ดหรือ vault

4.1 โครงสร้าง vault และ frontmatter

โครงสร้างโฟลเดอร์ที่ใช้ได้ผลคือแยกตามชนิดความรู้ ไม่ใช่ตามเวลา และมีหน้า 00-INDEX.md เป็นสารบัญให้ทั้งคนและ agent เริ่มจากตรงนี้

vault layout
/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/           # ข้อมูลดิบ ห้ามแก้
frontmatter ของทุกหน้า
---
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 ได้โดยไม่แตะข้อมูลเดิม

schema.sql
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()
);
ขีดจำกัดของ index

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 ในคู่มือนี้คาดหวัง:

embedding service contract (OpenAI-compatible)
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

วิธีที่เราประเมิน (ใช้ซ้ำได้)

  1. สุ่ม chunk จริงจากคลัง ให้ LLM สร้างคำถามที่ chunk นั้นตอบได้ (เราใช้ 45 คำถาม: ไทย 23 / อังกฤษ 22 บน 1,475 chunk)
  2. embed คำถามด้วยแต่ละ model แล้วดูว่า chunk ต้นทางอยู่อันดับเท่าไหร่
  3. รายงาน MRR และ Recall@k แยกตามภาษา
ModelMRRR@1R@5R@10
bge-m3 · 1024-d0.8160.760.910.91
embeddinggemma-2 · 768-d0.8720.800.960.98
embeddinggemma-2 · 512-d0.8800.820.961.00
embeddinggemma-2 · 256-d0.8060.710.910.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 เดียวต่อไฟล์

ingest.py (sketch)
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

mcp_server.py (sketch)
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

/etc/systemd/system/wiki-rag.service
[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):

Claude Code
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: connected

client อื่นที่รองรับ MCP over HTTP (Cursor, opencode, Cline ฯลฯ) ใช้ค่าเดียวกัน: URL ของ endpoint + header Authorization: Bearer ... แล้วเรียก tool ชื่อเดียวกัน

4.7 workflow การ re-ingest

  1. แก้/เพิ่มโน้ตใน vault พร้อม frontmatter ครบ และเพิ่มแถวใน 00-INDEX.md ถ้าเป็นหน้าใหม่
  2. Re-ingest เฉพาะไฟล์ที่แตะ — python ingest.py --only entities/db-server.md (หรือให้ agent เรียก tool reindex)
  3. ตรวจ — ค้นประโยคที่เพิ่งเขียนด้วย search_knowledge ต้องเจอหน้านั้นใน top 3
  4. งานเป็นรอบ — ตั้ง 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 ลำดับที่ปลอดภัย:

  1. สร้างตาราง vector ใหม่ขนานกับของเดิม (chunk_embeddings_<model> มิติตาม model ใหม่ + HNSW) ไม่แตะตารางเก่า
  2. Backfill — embed ทุก chunk ที่มีอยู่ด้วย model ใหม่ลงตารางใหม่ (ใช้เวลาตามจำนวน chunk; ของเราราว 26 นาทีสำหรับ 1,475 chunk)
  3. วัดผล ด้วยชุดคำถามของคุณเองทั้ง model เก่า-ใหม่ และ spot-check คำถามที่ผู้ใช้ถามบ่อย
  4. สลับด้วย provider switch — ให้ MCP server เลือกตารางจาก env var เช่น WIKI_SEARCH_PROVIDER=new|old แล้ว restart service เท่านั้น ไม่ต้องแก้โค้ด
  5. เก็บทางถอย — เก็บตารางเก่าไว้จนมั่นใจ (ตั้ง ingest ให้ embed ลงตาราง active เท่านั้นได้ แต่ต้องรู้ว่าถ้าถอยแล้วตารางเก่าไม่ทันโน้ตใหม่ ต้อง --force re-embed)
provider switch (sketch)
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
SECTION 5

ตัวอย่างการใช้งานกับ AI

5.1 คำสั่งสอน agent (ใส่ใน CLAUDE.md / system prompt)

CLAUDE.md snippet
## 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) ลง vault

5.2 ตัวอย่าง tool call และผลลัพธ์

request (MCP tools/call)
{
  "name": "search_knowledge",
  "arguments": { "query": "วิธีสำรองฐานข้อมูลก่อน migrate", "limit": 3 }
}
response (ย่อ)
{
  "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 เรียนรู้แล้วเขียนกลับ

  1. Agent ค้นพบข้อเท็จจริงใหม่ระหว่างทำงาน (เช่น วิธีแก้ error ที่ต้องลองหลายรอบ)
  2. เรียก search_knowledge เช็คก่อนว่ามีหน้าเดิมหรือไม่ — ถ้ามี ให้เพิ่ม section ใหม่ (append) ไม่เขียนทับ ถ้าข้อมูลขัดกัน ให้ใส่หมายเหตุ Contradiction แทนที่จะลบของเก่าเงียบๆ
  3. เขียนไฟล์ใน vault ตามโครงสร้างและ frontmatter (4.1) แล้ว bump updated: และเพิ่มแถวใน 00-INDEX.md ถ้าเป็นหน้าใหม่
  4. เรียก reindex(only="procedures/fix-xyz.md")
  5. ตรวจ: search_knowledge("<ประโยคที่เพิ่งเขียน>") ต้องได้หน้านั้นกลับมา — ถ้าไม่ได้ แปลว่า ingest พังหรือ chunk ไม่ดี
แยกความรู้ที่ควร/ไม่ควรเข้า vault

เข้า vault: ข้อเท็จจริงที่ใช้ข้ามโปรเจกต์ ขั้นตอนที่พิสูจน์แล้ว การตัดสินใจพร้อมเหตุผล ไม่เข้า: secret ทุกชนิด, ข้อมูลลูกค้า, ข้อมูลที่เปลี่ยนทุกนาที (ให้ดึงสดแทน), และสถานะชั่วคราวของงานที่กำลังทำ

SECTION 6

สำหรับ AI agent: ถ้าคุณกำลังจะสร้างระบบนี้

If you are an AI agent replicating this. ทำตามลำดับ ห้ามข้ามขั้น ทุกขั้นมีเงื่อนไขผ่านที่ตรวจได้ด้วยคำสั่ง ถ้าไม่ผ่านให้หยุดแก้ขั้นนั้นก่อน ห้ามเดาค่าที่เป็นของผู้ใช้ (host, รหัสผ่าน, path) — ถามผู้ใช้ แล้วเก็บ secret ใน environment variable เท่านั้น และห้ามพิมพ์ค่า secret ลง output/log

  1. 1เตรียม PostgreSQL 16+ พร้อม pgvector และสร้าง database แบบ UTF-8 ผ่านเมื่อ: SELECT extname FROM pg_extension WHERE extname='vector' ได้ 1 แถว และ SHOW server_encoding = UTF8
  2. 2เลือก embedding model แล้วทดสอบกับภาษาของผู้ใช้ก่อน (embed ประโยคต่างความหมาย 5+ ประโยค) ผ่านเมื่อ: cosine similarity ระหว่างประโยคต่างความหมายต่างกันชัดเจน (ไม่ใกล้ 1.0 ทั้งหมด) และจำนวนมิติใน response คงที่เท่ากันทุกครั้ง
  3. 3รัน embedding service และบันทึก prefix/input_type ของ query กับ document จากเอกสารของ model ผ่านเมื่อ: POST /v1/embeddings ได้ vector ความยาว = มิติของตาราง และ query กับ document ใช้ prefix คนละแบบตามที่ model กำหนด
  4. 4รัน schema.sql (4.2) โดยให้มิติใน vector(N) ตรงกับ model ผ่านเมื่อ: \d chunk_embeddings_g2 แสดงคอลัมน์ vector(N) และมี index แบบ hnsw
  5. 5สร้าง vault ตามโครงสร้างและ frontmatter (4.1) พร้อม 00-INDEX.md และใส่โน้ตจริงอย่างน้อย 10 หน้า ผ่านเมื่อ: ทุกหน้ามี frontmatter ครบ 5 ฟิลด์ และไม่มีค่า secret อยู่ในไฟล์ใดเลย (grep หา password, api_key, BEGIN PRIVATE KEY ได้ 0)
  6. 6เขียนและรัน ingest.py (4.4) ครั้งแรก ผ่านเมื่อ: SELECT count(*) FROM chunks > 0 และจำนวนแถวใน chunk_embeddings_* = จำนวนแถวใน chunks; รันซ้ำทันทีต้องข้ามทุกไฟล์ (mtime cache)
  7. 7ทดสอบ search ตรงใน SQL ก่อนทำ MCP: embed คำถาม 5 ข้อ (ไทยอย่างน้อย 2) ที่รู้คำตอบ ผ่านเมื่อ: หน้าที่ถูกต้องอยู่ใน top 3 อย่างน้อย 4 ใน 5 ข้อ ถ้าไม่ถึง ให้ตรวจ prefix, chunking และ model ก่อน
  8. 8เขียน MCP server (4.5) ตั้ง bearer token จาก env และรันเป็น service ผ่านเมื่อ: เรียกโดยไม่มี header ได้ 401; เรียกด้วย token ที่ถูกต้องผ่าน และ tool list มี search_knowledge, get_document, list_documents, reindex
  9. 9ต่อ MCP client (4.6) แล้วเรียก search_knowledge ผ่าน client จริง ผ่านเมื่อ: client แสดงสถานะ connected และ response เป็น JSON ที่มี results[].rel_path และ score
  10. 10เพิ่มคำสั่งสอน agent (5.1) ลงไฟล์ instruction ของ agent ผ่านเมื่อ: ถามคำถามที่ตอบได้จาก vault แล้ว agent เรียก search_knowledge ก่อนอ่านไฟล์ และอ้างอิง rel_path
  11. 11ทดสอบวงจรเขียนกลับ (5.3): เขียนหน้าใหม่ → reindex → ค้นเจอ ผ่านเมื่อ: ค้นประโยคที่เพิ่งเขียนแล้วหน้าใหม่อยู่ใน top 3 โดยไม่ต้อง restart server
  12. 12ตั้ง re-ingest อัตโนมัติ (cron/timer) และ backup ฐานข้อมูล ผ่านเมื่อ: แก้ไฟล์ 1 ไฟล์ รอรอบถัดไป แล้วค้นเจอเนื้อหาใหม่; pg_dump restore ลงฐานทดสอบได้

กฎที่ห้ามพลาด

  • ห้ามผสม vector จากคนละ model ในตารางเดียว และห้ามเปลี่ยน model โดยไม่ re-embed หรือสร้างตารางขนาน
  • ห้ามบอกว่า "เสร็จ" จนกว่าจะรันเช็คของขั้นนั้นแล้วเห็นผลจริง — อย่าเชื่อว่าโค้ดถูกเพราะอ่านแล้วดูถูก
  • ห้ามเปิด MCP endpoint สู่อินเทอร์เน็ตสาธารณะโดยไม่มี auth และไม่มี TLS
  • ห้ามใส่ secret ลง vault, โค้ด, หรือ log; อ้างถึงด้วยชื่อ env var เท่านั้น
  • ถ้าค่าที่ต้องใช้ขาด (host, DSN, token) ให้ถามผู้ใช้ ไม่ใช่สร้างค่าขึ้นเอง
SECTION 7

บทเรียนและ 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 ใหม่ทุก requestsearch ช้าโดยไม่จำเป็น (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 เดียวกัน) เปิดทางให้ค้นรูปด้วยข้อความ ถ้าต้องการในอนาคต