ENDMEMEX ทำงานอย่างไร — เจาะลึกสถาปัตยกรรม

บทความ เริ่มต้นใช้งาน บอกวิธีติดตั้งและใช้งาน บทความนี้เจาะลึกว่าข้างในทำงานอย่างไร — ENDMEMEX ไม่ใช่แค่ “ฐานข้อมูล SQLite เก็บ log การสนทนา” แต่ออกแบบมาให้แยกความรับผิดชอบของ “ความจำ” เป็น 3 แบบที่ชัดเจน แล้วยึดหลัก SQLite เป็น single source of truth เฉพาะบางเรื่องเท่านั้น ไม่ใช่ทุกเรื่อง
หลักคิดเบื้องหลัง: ความจำไม่ใช่สิ่งเดียว
ทีมพัฒนาแยกความจำออกเป็น 3 ชนิดที่ตั้งใจให้แยกจากกันชัดเจน เพราะแต่ละแบบมีคุณสมบัติที่ขัดแย้งกันถ้าพยายามยัดรวมไว้ที่เดียว:
- เนื้อหาที่ค้นหาได้ (searchable knowledge) — มาจากไฟล์ Markdown ที่ track ไว้ในโปรเจกต์ SQLite เป็นแค่ ดัชนีค้นหา ไม่ใช่ต้นฉบับ — ไฟล์ Markdown ยังเป็นความจริงเสมอ
- Record แบบถาวร (durable native records) — audit, fix, verification, decision, knowledge ที่เชื่อมกันด้วยความสัมพันธ์แบบมี lifecycle (เช่น “record นี้ resolves record นั้น”, “record นี้ verifies การแก้ไขนั้น”) นี่คือจุดเดียวที่ SQLite เป็นต้นฉบับจริง ไม่มีไฟล์อื่นอ้างอิงได้
- Session/checkpoint ที่ทำต่อได้ (resumable handoffs) — สถานะการทำงานปัจจุบัน ขั้นต่อไป จุดติดขัด ไฟล์ที่แก้ คำสั่งที่ใช้ — ออกแบบให้ agent ตัวถัดไป (หรือ session ถัดไปของตัวเอง) หยิบขึ้นมาทำต่อได้ทันทีโดยไม่ต้องอธิบายบริบทซ้ำ
การแยกนี้สำคัญเพราะสิ่งที่ตั้งใจไม่ทำ ก็ชัดเจนไม่แพ้กัน: ไม่ใช่ระบบ distributed, ไม่รองรับการเขียนพร้อมกันจากหลายเครื่องผ่าน filesystem sync, ไม่ใช่ filesystem watcher ที่ตรวจจับไฟล์เปลี่ยนอัตโนมัติ และไม่บังคับให้ต้องมี MiniLM/Torch/FastAPI ติดตั้งถึงจะใช้งานแบบ lexical ได้
ขอบเขต process: ทำไมแยก CLI หลักออกจาก embedding server
จุดออกแบบที่สำคัญที่สุดจุดหนึ่งคือ endeavor_db.py (CLI หลักที่ agent เรียกใช้) เขียนด้วย Python standard library ล้วนๆ ไม่ import ML package ใดๆ เลย — เป็นการตัดสินใจเชิงสถาปัตยกรรมโดยตั้งใจ เพื่อให้การค้นหาแบบ lexical, การจัดการ session, และการเขียน record ยังใช้งานได้แม้เครื่องนั้นไม่มี MiniLM/Torch ติดตั้งอยู่เลย
ส่วนที่ต้องใช้โมเดล embedding แยกออกไปเป็นprocess อื่นต่างหาก คือ embed_server.py — เป็น FastAPI/Uvicorn service ที่รันอยู่ที่ 127.0.0.1:8770 เพียงจุดเดียวที่ “ถือ” โมเดล MiniLM ไว้ในหน่วยความจำแบบ warm CLI หลักคุยกับ service นี้ผ่าน HTTP เท่านั้น ไม่เคย import Torch หรือ SentenceTransformers เข้ามาในตัวมันเอง
ยังมี process ที่ 3 คือ sync_tracked.py จัดการ sync ไฟล์ Markdown แบบ lexical-only (มี flag --no-embed) แยกออกมาอีกชั้นหนึ่ง
ทำไมต้องแยกขนาดนี้? เพราะถ้ารวมทุกอย่างไว้ใน process เดียว การเรียก CLI ธรรมดาๆ (เช่นดู session, ค้นคำ, เพิ่ม record) ก็จะต้องแบก Torch/MiniLM โหลดขึ้นมาทุกครั้งแม้ไม่ได้ใช้ embedding เลย — การแยกทำให้ lexical operation เร็วและเบา ในขณะที่ semantic search ยังพร้อมใช้เมื่อต้องการ โดยไม่ผูกติดกัน
Companion lifecycle: embedding server จัดการตัวเองยังไง
ensure_embed_server() เช็คก่อนว่ามี service ที่ compatible รันอยู่แล้วหรือยัง ถ้ามีก็ใช้ต่อ ไม่สร้างซ้ำ การ startup ถูก serialize ด้วยไฟล์ lock (.embed_start.lock) กัน agent หลายตัวแย่งกัน bind port 8770 พร้อมกัน
จุดที่ละเอียดคือการเลือก Python interpreter: ตัว launcher หาตัวที่มี package ที่จำเป็นครบ อิสระจาก sys.executable ของ CLI ที่กำลังรันอยู่ (เพราะ CLI หลักตั้งใจไม่มี ML package) และมี environment variable ENDEAVOR_EMBED_PYTHON ให้ override ได้ตรงๆ ถ้าต้องการชี้ interpreter เอง
Server โหลดโมเดลด้วย local_files_only=True — ไม่มีทางที่การ query จะไปกระตุ้นให้ดาวน์โหลดโมเดลเองแบบเงียบๆ ถ้าโมเดลยังไม่ถูก cache ไว้ก็จะ fail ชัดเจนแทนที่จะไปดึงจากเน็ตโดยไม่บอกใคร และ process จะปิดตัวเองอัตโนมัติหลังไม่มี request เข้ามา 1 ชั่วโมง คืนหน่วยความจำกลับให้เครื่อง
query mode มี 4 แบบ: auto (ใช้ semantic เฉพาะถ้า service พร้อมอยู่แล้ว ไม่ spawn ใหม่), on (spawn/รอถ้าจำเป็น), off (lexical อย่างเดียว), และ ready ภายในระบบ (ผ่านการ warm probe ครั้งเดียวมาแล้ว)
Lexical Retrieval: ค้นแบบ multi-pass แล้วรวมด้วย RRF
การค้นแบบ lexical ไม่ใช่ query เดียวจบ — แยกคำ (อย่างมาก 12 คำที่ไม่ใช่ stopword) เติม alias ไทย/อังกฤษที่ตรวจทานไว้แล้ว แล้วรันผ่าน 5 pass ที่มีน้ำหนักต่างกัน:
query ผู้ใช้
│
▼ แยกคำ + เติม alias ไทย/อังกฤษ
│
├──▶ Pass 1: ทุกคำ, Unicode FTS (น้ำหนัก 1.6)
├──▶ Pass 2: คำใดคำหนึ่ง, Unicode FTS (0.8)
├──▶ Pass 3: ทุกคำ, Porter FTS (1.2)
├──▶ Pass 4: trigram phrase (0.9)
└──▶ Pass 5: metadata rescue (1.5)
│
▼ รวมด้วย weighted reciprocal rank
┌────────────────────────┐
│ + boost: title ตรงเป๊ะ (+0.45) │
│ + boost: ครบทั้ง 2 ที่ (+0.25) │
│ − penalty: ความรู้เก่า (-0.05) │
└────────────────────────┘
│
▼
ผลลัพธ์จัดอันดับสุดท้าย
| Pass | น้ำหนัก | จุดประสงค์ |
|---|---|---|
| ทุกคำ, Unicode FTS | 1.6 | ตรงที่สุด (high-precision) |
| คำใดคำหนึ่ง, Unicode FTS | 0.8 | ครอบคลุมกว้าง (recall) |
| ทุกคำ, Porter FTS | 1.2 | รูปแบบคำอังกฤษ (stemming) |
| trigram phrase | 0.9 | กู้คืนคำที่เป็น identifier/substring |
| metadata rescue | 1.5 | รหัส bug/module/session ที่มีเครื่องหมายวรรคตอนเยอะ |
แต่ละ pass สมทบคะแนนแบบ weighted reciprocal rank แล้วรวมกันเป็นคะแนนสุดท้าย จากนั้นมีdeterministic boost เพิ่มเข้าไปอีกชั้น เช่น query ตรงกับ title เป๊ะ (+0.45), ทุกคำใน query ปรากฏทั้ง title และ content (+0.25), ตรงกับ intent/category ของ training-method (+0.50) — และมีpenalty สำหรับความรู้เก่าที่ถูก accept ไปแล้วแต่ historical (-0.05) เพื่อดันผลลัพธ์ปัจจุบันขึ้นแทนของเก่า
Semantic Retrieval: ขยายได้ถึงหลักแสน record โดยไม่ล้ม
Query vector ถูกเทียบกับ embedding — วิธีเทียบเปลี่ยนไปตามขนาดข้อมูล:
- ≤ 20,000 embedded rows ในขอบเขตที่ค้น: เทียบกับทุกแถวตรงๆ (exact scan) รักษาความแม่นยำเต็มที่รวมถึงกรณีคำค้นไม่มีคำซ้ำกับเอกสารเลยแต่ความหมายใกล้กัน (zero-keyword-overlap recall)
- มากกว่า 20,000: ใช้ HNSW sidecar index (เสริม ไม่บังคับ) ค้นจากชุด vector ทั้งหมด คืนมาสูงสุด 200 candidate ให้ไป cosine rerank แบบตรงต่ออีกที — sidecar นี้ผูกกับเครื่องนั้นๆ (machine-specific) และเช็ค snapshot ด้วยจำนวน record + timestamp ล่าสุด + generation number ของ SQLite ถ้าเก่ากว่าที่ควรจะเป็นแม้แค่วินาทีเดียวก็ไม่เชื่อถือ ระบบจะ fallback กลับไปใช้ lexical candidate แทนทันที
Cosine similarity คำนวณเป็นแค่ dot product ตรงๆ เพราะ vector ถูก normalize ไว้ตั้งแต่ตอนเก็บแล้ว ผลลัพธ์ semantic 30 อันดับแรกก็เข้าสูตร RRF เดียวกับฝั่ง lexical (1.0 / (60 + rank))
Unified Search: รวมสองคลังความรู้เข้าด้วยกัน
Query ปกติค้นทั้ง knowledge (จาก Markdown) และ current head ของ memory_records (SQLite-native) พร้อมกัน แต่ละคลังจัดอันดับของตัวเองก่อน แล้วเข้า RRF ชั้นที่สอง รวมสองอันดับเข้าด้วยกัน ถ้าคะแนนเท่ากันเป๊ะ ผลจาก SQLite-native ชนะ (ถือเป็น “current truth” ที่น่าเชื่อกว่า) แล้วค่อย fallback ไปใช้ ID ที่เสถียรตัดสินลำดับสุดท้าย
ผลจาก native record มี metadata กำกับชัดว่ามาจากไหน (source_path = SQLite:memory_records, match_reasons มี sqlite_native และ current_truth) — ป้องกันไม่ให้ระบบที่เรียกใช้สับสน ID จากสองคลังปะปนกัน
Lifecycle: record เก่าไม่หายไป แต่รู้ว่าอันไหนคือปัจจุบัน
การอ่าน native record ใดๆ จะ resolve ไปหา “component root” ของมัน (กลุ่ม record ที่เชื่อมกันด้วยความสัมพันธ์แบบ lifecycle) แล้วคืนข้อมูลกำกับสถานะกลับมา เช่น is_current, effective_status, has_unresolved_conflict — แม้ค้นด้วยคำเก่าที่ record ที่ถูก supersede ไปแล้วเคยใช้ ระบบก็ resolve กลับมาที่ record ปัจจุบันให้อัตโนมัติ (--current-only) โดยไม่ต้องให้เนื้อหาใหม่พูดคำเดิมซ้ำ
doctor ตรวจความถูกต้องของ lifecycle graph ทั้งหมดด้วยอัลกอริทึมของ Kahn (การเรียงลำดับแบบ topological) ทำงานในเวลาที่เป็นสัดส่วนกับจำนวน record+relation เท่านั้น ไม่ใช่การไล่หา transitive closure ที่ช้าลงเรื่อยๆ เมื่อข้อมูลโต
Concurrency: หลาย process บนเครื่องเดียว ไม่ใช่หลายเครื่องพร้อมกัน
ENDMEMEX รองรับหลาย process บน Mac เครื่องเดียวกันเท่านั้น ไม่ใช่หลายเครื่องเขียนพร้อมกันผ่าน filesystem sync — ใช้ SQLite WAL mode ให้ผู้อ่านทำงานได้ระหว่างมีผู้เขียนอยู่ บวก busy timeout ดูดซับการชนกันช่วงสั้นๆ และ BEGIN IMMEDIATE (แทน deferred transaction) เพื่อ serialize migration, การเขียนความสัมพันธ์ และการจัดลำดับ checkpoint ที่ถ้าปล่อยให้ race กันจะเกิดปัญหา
┌── Main Mac ─────────────────────────┐
│ agent 1 ─┐ │
│ agent 2 ─┼──▶ SQLite (WAL mode) │ ← หลาย process, เครื่องเดียว: OK
│ agent 3 ─┘ │
└──────────────────────────────────────┘
▲
│ HTTPS + idempotency receipt
│ (ผ่าน write_gateway.py เท่านั้น)
┌── Backup Mac ────────────────────────┐
│ เขียนลง outbox ตัวเองก่อน │
│ ✗ ห้ามเขียน SQLite ของ Main ตรงๆ │ ← ข้ามเครื่อง: ต้องผ่าน gateway เสมอ
└──────────────────────────────────────┘
สำหรับกรณีมีเครื่อง Backup ที่ต้องเขียนข้อมูลจริง มี write_gateway.py เป็นทางออกที่ควบคุมไว้ชัดเจน: เครื่อง Backup เขียนลง outbox ในเครื่องตัวเองก่อน แล้วส่ง request ที่อยู่ใน allowlist ผ่าน HTTPS ที่ authenticate แล้วไปยัง Main service เท่านั้น — Main service เก็บ idempotency receipt ไว้ก่อน dispatch จริง ถ้า key ซ้ำก็ replay receipt เดิม ไม่ทำซ้ำ และถ้า process ตายกลางทางตอนสถานะเป็น processing ระบบจะfail closed (ปฏิเสธไม่ทำต่อ) แทนที่จะเสี่ยงเขียนซ้ำ
Agent Operating Workflow: ลำดับที่ agent ควรทำ
ทีมพัฒนากำหนดลำดับการทำงานที่แนะนำสำหรับ agent ที่ต่อกับ ENDMEMEX ไว้ชัดเจน:
ก่อนเริ่มงานที่ไม่ trivial — เรียก handoff ดู session ที่เกี่ยวข้อง (ทำต่อของเดิมหรือสร้างใหม่) → รัน embed-backfill ครั้งเดียว (ล้มเหลวได้ ไม่บล็อกงาน) → ถ้า embedding ล้มเหลวจริง ให้รัน embed-diagnose ก่อนสรุปว่า “สภาพแวดล้อมมีปัญหา” (ห้ามเดาสาเหตุเอง) → ค้น memory ก่อนไปขุดงานเดิมซ้ำ → เปิดดูต้นฉบับ Markdown ที่ถูกอ้างถึงก่อนตัดสินใจเรื่องสำคัญ
ระหว่างทำงาน — เขียนความรู้ของโปรเจกต์ลง Markdown ที่เหมาะสม → รัน sync_tracked.py หลังแก้ไฟล์ที่ track ไว้ → เขียน native record ตรงๆ เมื่อต้องการ reference ที่เสถียร → checkpoint หลังจบแต่ละ phase สำคัญ หรือก่อน compact/สลับ agent
หลังทำงานเสร็จ — เพิ่ม verification record เมื่อ audit ถูกแก้แล้วจริง → รัน test ที่เกี่ยวข้องและ doctor → รัน retrieval evaluation ถ้าแก้ logic การค้นหรือ seed content → ปิด session ต่อเมื่อ verify แล้วว่าเป็นจริงเท่านั้น
ทำไมเรื่องนี้ถึงสำคัญ
ปัญหาคลาสสิกของ AI coding agent คือลืมทุกอย่างเมื่อ session จบ ต้องอธิบายบริบทซ้ำทุกครั้ง — ENDMEMEX แก้ปัญหานี้ไม่ใช่ด้วยการยัดทุกอย่างลง context window ให้ยาวขึ้น แต่ด้วยการแยกความจำตามคุณสมบัติจริงของมัน: อะไรควรค้นได้ (knowledge), อะไรต้องมี lifecycle ตรวจสอบได้ (records), อะไรแค่ต้องพกต่อไปยัง session ถัดไป (checkpoints) — และแยก process ที่กิน resource หนัก (embedding) ออกจาก process หลักที่ต้องเบาและเร็วเสมอ
ความคิดเห็น
กำลังโหลดความคิดเห็น...