LlamaIndex + pgvector: RAG ระดับ Production สำหรับเอกสารธุรกิจไทยและญี่ปุ่น

RAG demo ส่วนใหญ่ทำงานได้ดี แต่ระบบ RAG ที่นำขึ้น production จริงส่วนใหญ่ล้มเหลว — อย่างเงียบๆ เสียค่าใช้จ่ายสูง และแก้ไขปัญหายาก

หลังจากพัฒนา simpliDoc ซึ่งเป็นแพลตฟอร์ม AI document intelligence หลายภาษาของ Simplico เราได้เรียนรู้ความแตกต่างระหว่าง prototype ที่ทำงานได้กับระบบ production ที่รองรับเอกสารองค์กรภาษาไทย ญี่ปุ่น และอังกฤษพร้อมกัน บทความนี้แชร์สิ่งที่เรา deploy จริง: stack, ค่า config ที่ได้หลังการทดสอบ, failure modes ที่พบ และวิธีแก้ไข


ทำไมถึงเลือก stack นี้

ที่ Simplico เราใช้ LlamaIndex เป็น orchestration layer และ pgvector (PostgreSQL extension) เป็น vector store เหตุผลที่เลือกสองเครื่องมือนี้ร่วมกัน ไม่ใช่ทางเลือกอื่น:

  • LlamaIndex รองรับ multilingual embeddings และจัดการ chunking strategies ที่ทำงานได้ดีกับภาษาไทย ญี่ปุ่น และอังกฤษ ซึ่งมีพฤติกรรม tokenization ที่แตกต่างกันโดยพื้นฐาน
  • pgvector ทำงานภายใน PostgreSQL หมายความว่า vector data อยู่ใน database เดียวกับข้อมูลธุรกิจ ไม่ต้องการ infrastructure เพิ่มเติม ไม่มีความซับซ้อนของการ synchronization สำหรับลูกค้าองค์กรไทยที่ต้องปฏิบัติตาม พระราชบัญญัติคุ้มครองข้อมูลส่วนบุคคล (PDPA) และ พระราชบัญญัติการรักษาความมั่นคงปลอดภัยไซเบอร์ การเก็บข้อมูลทั้งหมดไว้ใน Postgres instance เดียวบน infrastructure ภายในประเทศถือเป็นข้อได้เปรียบด้าน compliance ที่สำคัญ

สถาปัตยกรรมระบบ

flowchart TD
    A["อัปโหลดเอกสาร\n(PDF / DOCX / TXT)"] --> B["LlamaIndex\nDocument parser"]
    B --> C["ตรวจจับภาษา\n(langdetect)"]
    C --> D["multilingual-e5-large\nEmbedding model"]
    D --> E["pgvector\n(PostgreSQL)"]
    F["คำถามจากผู้ใช้"] --> G["FastAPI\nRAG endpoint"]
    G --> D
    G --> E
    E --> H["Top-k retrieval\n(cosine similarity)"]
    H --> I["Claude API\nสร้างคำตอบ"]
    I --> J["Streaming response\n(SSE)"]

ขั้นตอนที่ 1: ตั้งค่า PostgreSQL + pgvector

-- เปิดใช้งาน pgvector extension
CREATE EXTENSION IF NOT EXISTS vector;

-- ตารางสำหรับ document chunks
CREATE TABLE document_chunks (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    doc_id      UUID NOT NULL,
    chunk_index INTEGER NOT NULL,
    content     TEXT NOT NULL,
    language    VARCHAR(10),           -- 'th', 'ja', 'zh', 'en'
    embedding   VECTOR(1024),          -- ขนาด output ของ multilingual-e5-large
    metadata    JSONB,
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

-- HNSW index สำหรับการค้นหา approximate nearest neighbour ที่รวดเร็ว
-- สำคัญมากเมื่อมีข้อมูลมากกว่า 50k chunks: ถ้าไม่มี query จะช้าจาก ~8ms เป็นกว่า 4 วินาที
CREATE INDEX ON document_chunks
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

เคล็ดลับสำหรับ Production: สร้าง HNSW index ก่อน go live ไม่ใช่ทีหลัง เราทำผิดพลาดโดยเพิ่ม index หลัง launch ที่ 80k chunks การสร้าง index ใช้เวลา 4 ชั่วโมงและทำให้ query latency สูงขึ้นระหว่างการสร้าง


ขั้นตอนที่ 2: Embedding model

เราใช้ multilingual-e5-large (1024 dimensions) จาก HuggingFace รองรับภาษาไทย ญี่ปุ่น จีนตัวย่อ และอังกฤษด้วย model เดียว ไม่ต้องจัดการ model แยกตามภาษา

from llama_index.embeddings.huggingface import HuggingFaceEmbedding

embed_model = HuggingFaceEmbedding(
    model_name="intfloat/multilingual-e5-large",
    max_length=512,
    device="cpu",        # ใช้ GPU ถ้ามี เราใช้ CPU บน VM 4 core
)

Throughput บนระบบของเรา: ประมาณ 60 chunks/วินาที บน VM CPU 4 core สำหรับ PDF 200 หน้าที่แบ่งเป็น ~600 chunks การ ingestion ใช้เวลาประมาณ 10 วินาที


ขั้นตอนที่ 3: Chunking strategy

นี่คือจุดที่โปรเจกต์ RAG ส่วนใหญ่ทำผิดพลาด ภาษาไทยและญี่ปุ่นไม่มีช่องว่างระหว่างคำ ทำให้การ chunking แบบนับตัวอักษรให้ผลลัพธ์ที่แตกต่างมากจากภาษาอังกฤษ

from llama_index.core.node_parser import SentenceSplitter

splitter = SentenceSplitter(
    chunk_size=400,       # ตัวอักษร ไม่ใช่ token
    chunk_overlap=80,     # overlap 20%
    paragraph_separator="\n\n",
)

ผลการทดสอบและเหตุผลที่เลือก 400/80:

Chunk size Overlap ผลลัพธ์
256 / 50 เล็กเกินไป ประโยคภาษาไทยถูกตัดกลางประโยค ทำให้ retrieval พลาด context
512 / 100 กลาง ดีสำหรับอังกฤษ แต่ภาษาไทย/ญี่ปุ่นยังแตกเป็นชิ้น
400 / 80 ที่เราเลือก คุณภาพ retrieval ดีที่สุดในทั้งสามภาษา
800 / 160 ใหญ่เกินไป คุณภาพ retrieval ดี แต่ cosine scores ของ pgvector แยกแยะได้น้อยลง

ก่อนปรับ (chunk size 256, ข้อความสัญญาภาษาไทย):

Chunk ที่ดึงมา: "...อัตราดอกเบี้ย ร้อยละสิบห้าต่อปี ในกรณีที่ผู้กู้ผิดนัดชำระ..."
คำตอบ: อัตราดอกเบี้ยคือ 15% ต่อปี
ขาดหาย: เงื่อนไขการผิดนัดอยู่ใน chunk ถัดไปและไม่ถูก retrieve มา

หลังปรับ (chunk size 400, เอกสารเดียวกัน):

Chunk ที่ดึงมา: "...อัตราดอกเบี้ย ร้อยละสิบห้าต่อปี ในกรณีที่ผู้กู้ผิดนัดชำระหนี้เกินกว่าสามสิบวัน..."
คำตอบ: อัตราดอกเบี้ยคือ 15% ต่อปี ใช้บังคับเมื่อชำระเงินล่าช้าเกิน 30 วัน

ขั้นตอนที่ 4: Ingestion pipeline

import asyncio
from llama_index.core import Document, VectorStoreIndex
from llama_index.vector_stores.postgres import PGVectorStore

async def ingest_document(file_path: str, doc_id: str, language: str):
    with open(file_path, "rb") as f:
        raw_text = extract_text(f)  # PDF/DOCX parser ของคุณ

    doc = Document(
        text=raw_text,
        metadata={"doc_id": doc_id, "language": language}
    )

    vector_store = PGVectorStore.from_params(
        database="simplidoc",
        host="localhost",
        port=5432,
        user="simplidoc_user",
        password=os.environ["DB_PASSWORD"],
        table_name="document_chunks",
        embed_dim=1024,
    )

    index = VectorStoreIndex.from_documents(
        [doc],
        embed_model=embed_model,
        transformations=[splitter],
        vector_store=vector_store,
    )

    return index

ขั้นตอนที่ 5: FastAPI RAG endpoint

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from anthropic import Anthropic

app = FastAPI()
client = Anthropic()

@app.post("/query")
async def query_documents(request: QueryRequest):
    query_engine = index.as_query_engine(
        similarity_top_k=5,
        streaming=True,
    )

    retrieval = await query_engine.aretrieve(request.query)
    context = "\n\n---\n\n".join([node.text for node in retrieval])

    system_prompt = """คุณคือผู้ช่วยด้านเอกสารสำหรับเอกสารธุรกิจองค์กร
ตอบคำถามโดยอ้างอิงจาก context ที่ให้มาเท่านั้น
หากคำตอบไม่อยู่ใน context ให้บอกอย่างชัดเจน
ตอบเป็นภาษาเดียวกับคำถาม"""

    async def stream_response():
        with client.messages.stream(
            model="claude-sonnet-4-20250514",
            max_tokens=1000,
            system=system_prompt,
            messages=[{
                "role": "user",
                "content": f"Context:\n{context}\n\nคำถาม: {request.query}"
            }]
        ) as stream:
            for text in stream.text_stream:
                yield f"data: {text}\n\n"

    return StreamingResponse(stream_response(), media_type="text/event-stream")

Failure modes ที่เราพบในระหว่าง Production

1. Embedding model cold start (ล่าช้า 12 วินาทีในการ query ครั้งแรก)

Model multilingual-e5-large โหลดเข้า RAM เมื่อใช้งานครั้งแรก ทำให้มีการล่าช้า 12 วินาทีใน query แรกของแต่ละ session

แก้ไข: Warm model เมื่อ startup

@app.on_event("startup")
async def warm_embedding_model():
    _ = embed_model.get_text_embedding("warmup")

2. การ extract PDF ภาษาไทยได้ข้อความผิดเพี้ยน

PDF ภาษาไทยบางไฟล์ใช้ font encoding ที่ไม่ได้มาตรฐาน PyPDF2 extract ออกมาเป็นอักขระผิดปกติ วิธีแก้: ใช้ pdfplumber สำหรับเอกสารภาษาไทย

import pdfplumber

def extract_thai_pdf(path: str) -> str:
    with pdfplumber.open(path) as pdf:
        return "\n".join(page.extract_text() or "" for page in pdf.pages)

3. pgvector cosine similarity ดึง chunk ที่ไม่เกี่ยวข้องมาที่ threshold ต่ำ

ในตอนแรกเราส่งคืน chunk ที่มี cosine similarity เกิน 0.5 chunk บางรายการที่ไม่เกี่ยวข้องได้คะแนน 0.55–0.65 สำหรับคำถามที่ไม่ชัดเจน

แก้ไข: เพิ่ม threshold และเพิ่มขั้นตอน reranking

retrieval = await query_engine.aretrieve(request.query)
filtered = [n for n in retrieval if n.score >= 0.72]

ต้นทุนและประสิทธิภาพ

ใช้งานบน VM 4-core / 8GB RAM (ประมาณ ฿1,200/เดือน บน cloud provider ในไทย):

ตัวชี้วัด ค่า
Query latency เฉลี่ย (warm) 1.8 วินาที end-to-end
Embedding throughput ~60 chunks/วินาที
pgvector search (HNSW, 200k chunks) ~8ms
ค่าใช้จ่าย Claude API ต่อ query ~฿0.004 ที่ usage level ของเรา
เอกสารที่ index แล้ว 12,000+ ไฟล์ ในภาษาไทย/ญี่ปุ่น/อังกฤษ

หมายเหตุด้าน Data Residency

สำหรับการ deploy ในองค์กรไทยภายใต้ PDPA: embedding และเนื้อหาเอกสารทั้งหมดอยู่ใน PostgreSQL instance ของคุณ ไม่มีการส่งเนื้อหาเอกสารไปยัง external embedding API เนื่องจาก multilingual-e5-large รันในเครื่อง มีเพียง context ที่ retrieve มาเท่านั้นที่ถูกส่งไปยัง Claude API เพื่อสร้างคำตอบ ไม่ใช่เอกสารทั้งหมด

สำหรับองค์กรที่มีข้อกำหนด data residency เข้มงวดยิ่งขึ้น สามารถแทนที่ Claude API ด้วย model ที่ host ในเครื่องสำหรับขั้นตอนการสร้างคำตอบได้


บทความที่เกี่ยวข้อง

ต้องการระบบ RAG หลายภาษาสำหรับเอกสารองค์กรของคุณ? ติดต่อ Simplico — เราพัฒนาระบบนี้ให้กับลูกค้าทั้งในไทย ญี่ปุ่น และตลาดโลก


Get in Touch with us

Chat with Us on LINE

iiitum1984

Speak to Us or Whatsapp

(+66) 83001 0222

Related Posts

Our Products