SSD Nodes Learn 🎉 VPS เริ่ม $5.50/เดือน
คู่มือ Matt Connorโดย Matt Connor

วิธีสร้าง RAG Pipeline บน VPS ด้วยตัวเองแบบครบวงจร

เรียนรู้วิธีสร้าง RAG Pipeline บน VPS ของคุณเอง ตั้งแต่การทำ Chunking การใช้โมเดล Embedding ผ่าน Ollama ไปจนถึงการตั้งค่า pgvector บน PostgreSQL พร้อมตัวอย่าง SQL ที่ใช้งานได้จริง

โครงสร้างของ RAG pipeline แบบ self-hosted

RAG pipeline (retrieval augmented generation) ประกอบด้วย 5 ขั้นตอน ได้แก่ การแบ่งเอกสารเป็นส่วนย่อย (chunk), การแปลงส่วนย่อยเหล่านั้นเป็น embedding, การจัดเก็บเวกเตอร์, การดึงข้อมูลที่ใกล้เคียงที่สุดสำหรับคำถาม และการส่งข้อมูลเหล่านั้นไปยังโมเดลภาษาเพื่อเขียนคำตอบ บน VPS ที่คุณเช่าใช้งานอยู่แล้ว ขั้นตอนทั้ง 4 แรกจะทำงานบนเซิร์ฟเวอร์ของคุณ โดยใช้ PostgreSQL ร่วมกับส่วนขยาย pgvector ในการเก็บเวกเตอร์ และใช้โมเดล embedding ขนาดเล็กที่ให้บริการผ่าน Ollama ในการแปลงข้อความเป็นเวกเตอร์ มีเพียงขั้นตอนสุดท้ายเท่านั้นที่จำเป็นต้องส่งออกไปภายนอก

การแบ่งส่วนการทำงานเช่นนี้คือหัวใจสำคัญของคู่มือฉบับนี้ การทำ chunking เป็นงานที่ใช้ CPU ตามปกติ ส่วนการทำ embedding เป็นโมเดลที่มีพารามิเตอร์ 137 ล้านตัว ซึ่งใช้ RAM เพียงไม่กี่ร้อยเมกะไบต์เท่านั้น ในส่วนของการจัดเก็บข้อมูลนั้นเป็นตารางใน Postgres ซึ่งคุณสามารถคำนวณขนาดที่ต้องใช้ได้ด้วยคณิตศาสตร์ก่อนที่จะเริ่มเขียนข้อมูลแม้แต่แถวเดียว สำหรับคลังข้อมูลที่มีจำนวน chunk ในระดับหลายแสนรายการ งานทั้งหมดนี้สามารถรันบน VPS ทั่วไปได้ ส่วนขั้นตอนการสร้างคำตอบ (generation) นั้นแตกต่างออกไป เนื่องจากมีค่าใช้จ่ายเกิดขึ้นในทุกคำถามอย่างต่อเนื่อง

ส่วนประกอบใดของ RAG pipeline ที่มีค่าใช้จ่ายจริง

บทช่วยสอน RAG แบบ end to end ของ DigitalOcean เลือกใช้ managed vector database และ embedding model แบบ hosted ซึ่งส่วนของค่าใช้จ่ายนั้นเป็นเชิงคุณภาพ เช่น การทำ cache สำหรับคำถามที่ถามซ้ำ, การจำกัดจำนวน chunk ที่ดึงมา และการทำ rerank ก่อนการสร้างคำตอบ คำแนะนำเหล่านั้นถูกต้อง แต่บทช่วยสอนดังกล่าวข้ามตัวเลือกที่เปลี่ยนสมการต้นทุนไปอย่างสิ้นเชิง นั่นคือการรัน embedding model บนเซิร์ฟเวอร์ที่คุณจ่ายเงินเช่าอยู่แล้ว

ให้คำนวณเป็นจำนวน token แทนที่จะเป็นจำนวนเงิน เพราะจำนวน token ไม่เปลี่ยนแปลงตามราคาที่ปรับใหม่ สมมติว่าคุณมีคลังข้อมูลขนาด 100,000 chunks โดยแต่ละ chunk มี 400 tokens, มีการถามคำถาม 10,000 ครั้ง, ดึงข้อมูล 8 chunks ต่อการตอบหนึ่งครั้ง, มีบล็อกคำถามและคำสั่ง 100 tokens และคำตอบ 400 tokens

ChartToken load for a 100,000 chunk corpus and 10,000 questions
The data behind this chart
[
  {
    "label": "Embed the corpus (once)",
    "tokens_millions": 40,
    "tokens_per_question": "4,000"
  },
  {
    "label": "Embed each question",
    "tokens_millions": 0.2,
    "tokens_per_question": "20"
  },
  {
    "label": "Generation input",
    "tokens_millions": 33,
    "tokens_per_question": "3,300"
  },
  {
    "label": "Generation output",
    "tokens_millions": 4,
    "tokens_per_question": "400"
  }
]

การทำ embedding ให้กับคลังข้อมูลทั้งหมดคิดเป็น 40 ล้าน tokens ซึ่งเกิดขึ้นเพียงครั้งเดียว หากเฉลี่ยกับคำถาม 10,000 ครั้ง จะเท่ากับ 4,000 tokens ต่อคำถาม หากถามคำถามหนึ่งแสนครั้ง ตัวเลขนี้จะลดลงเหลือ 400 แต่ขั้นตอนการสร้างคำตอบ (generation) จะไม่ลดลงเลย โดยมีค่าใช้จ่ายขาเข้า 3,300 tokens และขาออก 400 tokens ในทุกคำถามที่คุณตอบ

ดังนั้น ค่าใช้จ่ายจะขึ้นอยู่กับขั้นตอนที่ทำซ้ำ คุณควรจัดการขั้นตอน embedding เอง เพราะคุณจ่ายเงินเพียงครั้งเดียวและ VPS ก็รันอยู่ตลอดเวลาอยู่แล้ว ส่วนขั้นตอน generation ควรซื้อบริการจากภายนอก เพราะเป็นจุดที่โมเดลที่ดีกว่าคุ้มค่ากับเงินที่จ่ายไป การทำ cache มีความสำคัญด้วยเหตุผลเดียวกัน คือการ hit cache จะช่วยข้ามขั้นตอนเดียวที่ต้นทุนไม่เคยลดลงตามปริมาณการใช้งาน ความแตกต่างระหว่าง KV cache และ prompt cache จะเป็นตัวตัดสินว่าคุณสามารถนำส่วนใดกลับมาใช้ใหม่ได้ และ prompt ของ RAG มักมีบล็อกคำสั่งที่คงที่ตามด้วยบล็อกข้อมูล chunk ที่เปลี่ยนแปลง ซึ่งเป็นรูปแบบที่ได้รับประโยชน์จากวิธีนี้มากที่สุด

การแบ่งส่วนข้อมูล (Chunking): เหตุใดขนาดคงที่พร้อมส่วนซ้อนทับจึงเป็นค่าเริ่มต้นที่เหมาะสม

Chunk คือหน่วยข้อมูลที่คุณเรียกใช้งาน ดังนั้นขนาดของมันจึงเป็นตัวกำหนดทุกอย่างในขั้นตอนถัดไป ขนาดของ Chunk ต้องเล็กพอที่จะทำให้ embedding สื่อถึงเรื่องใดเรื่องหนึ่งเพียงเรื่องเดียว เนื่องจาก embedding คือจุดเดียวในพื้นที่เวกเตอร์ หาก Chunk ครอบคลุมถึงสี่หัวข้อ จุดนั้นจะไปตกอยู่ระหว่างหัวข้อเหล่านั้นและไม่ใกล้เคียงกับหัวข้อใดเลย นอกจากนี้ Chunk ต้องใหญ่พอที่จะตอบคำถามได้ด้วยตัวเอง เพราะโมเดลภาษาจะมองเห็นเพียงแค่ Chunk นั้น ไม่ใช่เอกสารทั้งหมดที่อยู่โดยรอบ

ให้เริ่มต้นที่ 300 คำโดยมีส่วนซ้อนทับ (overlap) 50 คำ ภาษาอังกฤษมีอัตราส่วนประมาณ 1.3 โทเค็นต่อคำ ดังนั้น 300 คำจึงเท่ากับประมาณ 400 โทเค็น การมีส่วนซ้อนทับมีความจำเป็นเพราะประโยคที่ตกอยู่ตรงรอยต่อจะถูกตัดแบ่งครึ่ง ซึ่งทั้งสองส่วนจะไม่สามารถตอบคำถามได้

ให้แบ่งตามโครงสร้างก่อนหากเอกสารมีโครงสร้างที่ชัดเจน โดยให้ตัดแบ่งที่หัวข้อ (headings) จากนั้นจึงตัดที่ย่อหน้า (paragraphs) และใช้กฎขนาดคงที่เฉพาะภายในส่วนที่ยังยาวเกินไปเท่านั้น Chunk ที่เริ่มต้นกลางประโยคจะทำให้อ่านได้ยากในคำตอบสุดท้าย เนื่องจากโมเดลจะอ้างอิงข้อมูลกลับมาจากสิ่งที่คุณป้อนให้

อย่าปรับแต่งการแบ่งส่วนข้อมูลก่อนที่คุณจะสามารถวัดผลได้ การแบ่งแบบขนาดคงที่พร้อมส่วนซ้อนทับเป็นวิธีที่กำหนดผลลัพธ์ได้แน่นอนและประหยัดทรัพยากรในการประมวลผลใหม่ ซึ่งทำให้มันเป็นค่าพื้นฐาน (baseline) ที่คุณสามารถนำไปเปรียบเทียบเพื่อพัฒนาให้ดีขึ้นได้ ให้สร้างระบบการสืบค้นเพื่อวัดคะแนน (scoring query) ในขั้นตอนถัดไปให้เรียบร้อยก่อน แล้วจึงค่อยปรับเปลี่ยนทีละอย่าง

การฝังโมเดลบนเครื่องเดียวกัน และต้นทุนด้าน RAM กับ Latency

curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-text

nomic-embed-text มีพารามิเตอร์ 137 ล้านตัวและมีขนาดไฟล์ดาวน์โหลด 274 MB ณ เดือนสิงหาคม 2026 ให้ตรวจสอบผลลัพธ์ที่ได้ก่อนออกแบบตารางเพื่อรองรับข้อมูลดังกล่าว

curl -s http://127.0.0.1:11434/api/embed \
  -d '{"model": "nomic-embed-text", "input": "search_document: hello"}' |
  python3 -c 'import json,sys; print(len(json.load(sys.stdin)["embeddings"][0]))'

คำสั่งนั้นจะพิมพ์ค่า 768 ออกมา ประเภทของคอลัมน์ในฐานข้อมูลของคุณต้องตรงกับตัวเลขนั้นทุกประการ

มีการตั้งค่าสองอย่างในโมเดลนี้ที่มักทำให้ผู้ใช้งานสับสน

Task prefix เป็นสิ่งที่จำเป็นต้องใส่ โมเดลการ์ดของ Nomic ระบุว่าอินพุต "ต้องมี task instruction prefix" เสมอ เอกสารที่นำมาฝังต้องมี search_document: นำหน้า ส่วนคำถามต้องมี search_query: นำหน้า หากคุณละเว้นส่วนนี้ไป ระบบจะไม่แจ้งเตือนข้อผิดพลาดใดๆ คุณจะยังคงได้รับเวกเตอร์กลับมา แต่คุณภาพในการสืบค้นจะลดลง และไม่มี log บรรทัดใดแจ้งให้คุณทราบถึงสาเหตุ

อินพุตที่มีความยาวเกินจะถูกตัดทอนโดยไม่มีการแจ้งเตือน endpoint /api/embed รับฟิลด์ truncate โดยมีค่าเริ่มต้นที่ true และโมเดลในรูปแบบที่ Ollama จัดเตรียมไว้ระบุว่ารองรับ context สูงสุดที่ 2K หาก chunk ข้อมูลยาวเกินกว่านั้น ระบบจะตัดทอนที่ขีดจำกัดดังกล่าวและทำการฝังข้อมูลต่อไป ทำให้ส่วนท้ายของข้อมูลไม่สามารถสืบค้นได้ ให้ส่งค่า "truncate": false ในระหว่างการทดสอบ เพื่อให้ chunk ที่มีขนาดใหญ่เกินไปแจ้งเตือนข้อผิดพลาดแทนที่จะผ่านไปได้โดยไม่ตรวจสอบ

ให้ทำ batch requests และคงสถานะโมเดลไว้ในหน่วยความจำ

curl -s http://127.0.0.1:11434/api/embed -d '{
  "model": "nomic-embed-text",
  "input": ["search_document: first chunk", "search_document: second chunk"],
  "keep_alive": "30m"
}' > /dev/null

input รองรับการส่งข้อมูลเป็นรายการ (list) การส่งคำขอเดียวที่มี 32 chunks จะมีประสิทธิภาพดีกว่าการส่ง 32 คำขอแยกกัน เนื่องจากช่วยลดจำนวนรอบการรับส่ง HTTP และการค้นหาโมเดลให้เหลือเพียงครั้งเดียวแทนที่จะเป็น 32 ครั้ง keep_alive เป็นตัวกำหนดระยะเวลาที่โมเดลจะคงอยู่ในหน่วยความจำหลังจากประมวลผลคำขอเสร็จสิ้น โดยค่าเริ่มต้นคือ 5 นาที เมื่อครบกำหนดเวลา คำขอถัดไปจะต้องเสียเวลาโหลดโมเดลใหม่อีกครั้ง

ให้วัดค่าสองตัวแปรที่สำคัญบนเครื่องของคุณเอง เนื่องจากค่าเหล่านี้ขึ้นอยู่กับจำนวน vCPU ที่คุณมี ดังนั้นตัวเลขที่เผยแพร่ทั่วไปจึงอาจไม่ตรงกับกรณีของคุณ

ollama ps
time curl -s http://127.0.0.1:11434/api/embed \
  -d '{"model":"nomic-embed-text","input":"search_document: ... one real chunk ..."}' > /dev/null

ollama ps จะแสดงขนาดหน่วยความจำที่โมเดลถูกโหลดไว้ ซึ่งเป็น RAM ที่คุณต้องสำรองไว้ตราบเท่าที่ keep_alive ยังคงเก็บโมเดลนั้นไว้ ผลลัพธ์จาก time เมื่อหารด้วยขนาด batch จะได้ค่าเวลาเป็นวินาทีต่อ chunk ให้นำไปคูณกับจำนวน chunk ทั้งหมดเพื่อคำนวณต้นทุนเวลาในการทำ indexing ทั้งหมด หากใช้เพียง CPU คาดการณ์ได้ว่าคลังข้อมูลขนาด 100,000 chunks อาจต้องใช้เวลาหลายชั่วโมงแทนที่จะเป็นนาที ซึ่งถือเป็นเรื่องปกติเพราะเป็นการดำเนินการเพียงครั้งเดียวและสามารถรันข้ามคืนด้วย nice -n 19 ได้ หากระยะเวลาหลายชั่วโมงไม่ตอบโจทย์ คำถามที่แท้จริงคือการเช่า GPU จะคุ้มค่ากว่าหรือไม่ ซึ่งเป็นเรื่องของ การคำนวณจุดคุ้มทุนเทียบกับค่า API tokens ไม่ใช่แค่ความพึงพอใจส่วนตัว

หากเครื่องของคุณให้บริการ chat model อยู่แล้ว โมเดลสำหรับ embedding จะกลายเป็นโมเดลตัวที่สองที่ต้องใช้ RAM เพิ่มขึ้น การรัน Ollama บน VPS ครอบคลุมเรื่องการจัดสรรทรัพยากรสำหรับฝั่ง generation และ สิ่งที่โมเดลที่โฮสต์เองต้องเผชิญเมื่อมีผู้ใช้พร้อมกันหลายคน อธิบายถึงสิ่งที่เกิดขึ้นเมื่อมีผู้ใช้งานหลายคนส่งคำถามเข้ามาพร้อมกัน โมเดล embedding มีขนาดเล็กพอที่จะรันควบคู่ไปกับโมเดลอื่นได้

สคริปต์สำหรับการทำ indexing ตั้งแต่ต้นจนจบ

บน Ubuntu 24.04 การรัน pip install โดยตรงนอก virtual environment จะหยุดทำงานพร้อมข้อผิดพลาด error: externally-managed-environment เนื่องจาก Python ของระบบถูกจัดการโดย apt

python3 -m venv ~/rag
~/rag/bin/pip install "psycopg[binary]" pgvector
import json, urllib.request
import psycopg
from pgvector.psycopg import register_vector
from pgvector import Vector

OLLAMA = "http://127.0.0.1:11434/api/embed"
MODEL = "nomic-embed-text"

def embed(texts, prefix="search_document: "):
    payload = {"model": MODEL,
               "input": [prefix + t for t in texts],
               "truncate": False,
               "keep_alive": "30m"}
    req = urllib.request.Request(OLLAMA, data=json.dumps(payload).encode(),
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req) as resp:
        return json.load(resp)["embeddings"]

def split(text, size=300, overlap=50):
    words = text.split()
    step = size - overlap
    return [" ".join(words[i:i + size]) for i in range(0, len(words), step)]

with psycopg.connect("dbname=rag user=rag") as conn:
    register_vector(conn)
    for doc_id, text in documents():          # your loader
        pieces = split(text)
        for start in range(0, len(pieces), 32):
            batch = pieces[start:start + 32]
            vectors = embed(batch)
            with conn.cursor() as cur:
                cur.executemany(
                    "INSERT INTO chunks (doc_id, seq, body, embedding)"
                    " VALUES (%s, %s, %s, %s)",
                    [(doc_id, start + i, body, Vector(vec))
                     for i, (body, vec) in enumerate(zip(batch, vectors))])
        conn.commit()

documents() เป็นส่วนของคุณ: ไม่ว่าจะเป็นการวนลูปอ่านไฟล์หรือแถวข้อมูล และส่งคืน document id พร้อมข้อความ ส่วนที่เหลือทั้งหมดคือขั้นตอนการประมวลผล (pipeline)

พื้นที่จัดเก็บ: สคีมา pgvector และขนาดที่เพิ่มขึ้น

Ubuntu 24.04 มาพร้อมกับ postgresql-16-pgvector เวอร์ชัน 0.6.0 ซึ่งเก่ากว่าประเภท halfvec ให้ใช้ที่เก็บแพ็กเกจของโครงการ PostgreSQL โดยตรงเพื่อให้ได้รุ่นล่าสุด

sudo apt update && sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-17 postgresql-17-pgvector

ตัวเลขในชื่อแพ็กเกจต้องตรงกับเวอร์ชันหลักของเซิร์ฟเวอร์ จากนั้นให้สร้าง role, database และ extension

sudo -u postgres createuser --pwprompt rag
sudo -u postgres createdb --owner rag rag
sudo -u postgres psql -d rag -c 'CREATE EXTENSION vector;'
CREATE TABLE chunks (
  id        bigserial PRIMARY KEY,
  doc_id    text NOT NULL,
  seq       int  NOT NULL,
  body      text NOT NULL,
  embedding vector(768) NOT NULL,
  fts       tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED
);

CREATE INDEX chunks_fts ON chunks USING gin (fts);

vector(768) ต้องตรงกับผลลัพธ์ของโมเดล หากคุณแทรกเวกเตอร์ขนาด 1024 ลงในคอลัมน์ดังกล่าว Postgres จะปฏิเสธด้วยข้อความ expected 768 dimensions, not 1024 ซึ่งเป็นข้อความแสดงข้อผิดพลาดที่ชัดเจนที่สุดในกระบวนการทั้งหมด คอลัมน์ fts ที่สร้างขึ้นมานั้นไม่มีค่าใช้จ่ายในการดูแลรักษา และช่วยให้คุณสามารถทำ keyword search ได้ในภายหลัง

การจัดเก็บข้อมูลเป็นเรื่องของคณิตศาสตร์ เอกสารของ pgvector ระบุว่า vector มีขนาด 4 * dimensions + 8 ไบต์ และ halfvec มีขนาด 2 * dimensions + 8 จำนวนมิติข้อมูลด้านล่างคือขนาดผลลัพธ์ที่เผยแพร่ของแต่ละโมเดล

ChartVector column size per 100,000 chunks, by embedding dimension
The data behind this chart
[
  {
    "label": "384 (all-minilm)",
    "bytes_per_vector": "1,544",
    "vector_mib_per_100k": 147,
    "halfvec_mib_per_100k": 74
  },
  {
    "label": "768 (nomic-embed-text)",
    "bytes_per_vector": "3,080",
    "vector_mib_per_100k": 294,
    "halfvec_mib_per_100k": 147
  },
  {
    "label": "1024 (mxbai-embed-large)",
    "bytes_per_vector": "4,104",
    "vector_mib_per_100k": 391,
    "halfvec_mib_per_100k": 196
  },
  {
    "label": "1536 (hosted API model)",
    "bytes_per_vector": "6,152",
    "vector_mib_per_100k": 587,
    "halfvec_mib_per_100k": 294
  }
]

ที่ 768 มิติ เวกเตอร์แต่ละตัวจะมีขนาด 3,080 ไบต์ ดังนั้นข้อมูล 100,000 ชิ้นจะใช้พื้นที่ 294 MiB สำหรับข้อมูลเวกเตอร์ หากใช้คลังข้อมูลเดียวกันกับโมเดลที่ให้ผลลัพธ์ 1536 มิติ จะต้องใช้พื้นที่ 587 MiB และดัชนีที่ครอบคลุมข้อมูลนั้นก็จะขยายตัวตามสัดส่วน การใช้ half precision จะช่วยลดขนาดลงครึ่งหนึ่ง โดย halfvec(768) จะจัดเก็บคลังข้อมูลนั้นในขนาด 147 MiB ส่วนเรื่องที่ว่ามันจะส่งผลต่อค่า recall หรือไม่นั้น เป็นคำถามที่ query การให้คะแนนด้านล่างสามารถตอบได้ในการรันเพียงครั้งเดียว

ตัวเลขเหล่านี้ครอบคลุมเฉพาะคอลัมน์เวกเตอร์เท่านั้น ข้อมูลข้อความ, overhead ของแถว และดัชนีต่างๆ จะถูกเพิ่มเข้าไปอีก ดังนั้นควรวัดขนาดตารางจริง

SELECT pg_size_pretty(pg_total_relation_size('chunks')) AS total,
       pg_size_pretty(pg_relation_size('chunks'))       AS heap,
       count(*) AS n_rows
FROM chunks;

หากคุณต้องการ extension เดียวกันที่มี API และระบบจัดการบัญชีผู้ใช้ ชุดซอฟต์แวร์ Supabase แบบ self-hosted คือ Postgres ที่เปิดใช้งาน pgvector ไว้แล้ว และทุก query ในคู่มือนี้สามารถใช้งานได้ทันทีโดยไม่ต้องแก้ไขใดๆ

การทำ Indexing: การตั้งค่า HNSW ที่สำคัญ

หากข้อมูลมีจำนวนไม่กี่พันแถว ให้ข้ามการทำ index ไปก่อน การค้นหาแบบแม่นยำ (Exact search) จะอ่านทุกแถวซึ่งเพียงพอและรวดเร็วสำหรับข้อมูลขนาดนี้ อีกทั้งยังให้ผลลัพธ์ที่ถูกต้องสมบูรณ์ ให้เริ่มทำ index เมื่อการสแกนแบบลำดับ (sequential scan) เริ่มช้าเกินไป และต้องเข้าใจข้อแลกเปลี่ยนที่เกิดขึ้น: index แบบประมาณการ (approximate index) จะส่งคืนผลลัพธ์ที่เป็นเพื่อนบ้านที่ใกล้เคียงเท่านั้น

SET maintenance_work_mem = '2GB';
SET max_parallel_maintenance_workers = 3;
CREATE INDEX chunks_embedding ON chunks
  USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);

m = 16 และ ef_construction = 64 คือค่าเริ่มต้นของ pgvector การเพิ่มค่าเหล่านี้จะช่วยให้ความแม่นยำ (recall) ดีขึ้น แต่ต้องแลกด้วยเวลาในการสร้าง index และขนาดของ index ที่ใหญ่ขึ้น ให้ใช้ vector_cosine_ops ร่วมกับตัวดำเนินการ <=> เว้นแต่คุณจะมั่นใจว่าโมเดลของคุณส่งออกเวกเตอร์ที่มีความยาวหนึ่งหน่วย (unit length) เนื่องจาก cosine distance จะไม่สนใจความยาวของเวกเตอร์ ในขณะที่ inner product จะนำความยาวมาคำนวณด้วย

คอยสังเกตกระบวนการสร้าง index เมื่อกราฟมีขนาดใหญ่เกินกว่า maintenance_work_mem ทาง pgvector จะแจ้งเตือนดังนี้:

NOTICE:  hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL:  Building will take significantly more time.

นี่ไม่ใช่ข้อผิดพลาดและกระบวนการสร้างจะเสร็จสมบูรณ์ แต่ระบบจะเปลี่ยนไปใช้เส้นทางที่ช้ากว่ามาก ให้เพิ่มค่า maintenance_work_mem ใน session ที่กำลังสร้าง index เท่านั้น และคงค่าเริ่มต้นของเซิร์ฟเวอร์ไว้ตามเดิม เนื่องจากการตั้งค่านี้มีผลต่อการบำรุงรักษาแต่ละครั้ง และการตั้งค่าส่วนกลางไว้สูงเกินไปอาจทำให้หน่วยความจำของเครื่องเต็มได้ ให้ติดตามสถานะการสร้าง index ที่ใช้เวลานานจากอีก session หนึ่ง:

SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS "%"
FROM pg_stat_progress_create_index;

จากนั้นให้เปรียบเทียบขนาดของ index ที่สร้างเสร็จแล้วกับหน่วยความจำที่มีในเครื่อง:

SELECT pg_size_pretty(pg_relation_size('chunks_embedding'));
SHOW shared_buffers;

การค้นหาแบบ HNSW จะเป็นการเดินไปตามกราฟ ดังนั้นมันจะเข้าถึงหน้าข้อมูล (pages) ที่กระจัดกระจายอยู่ทั่ว index แทนที่จะอ่านข้อมูลเป็นช่วง หาก index ไม่สามารถโหลดลงในหน่วยความจำได้ จะทำให้ทุกการค้นหากลายเป็นการอ่านจากดิสก์ ซึ่งความล่าช้าที่เกิดขึ้นนี้คือสิ่งที่ผู้ใช้งานจะสัมผัสได้ นี่คือกฎการกำหนดขนาดเซิร์ฟเวอร์เพียงข้อเดียว: ขนาดของ index รวมกับแถวข้อมูลที่คุณให้บริการจริงควรจะพอดีกับ RAM ค่า free -m และขนาดที่ระบุข้างต้นคือตัวเลขสองค่าที่ต้องนำมาเปรียบเทียบกัน

ในขณะที่ทำการค้นหา hnsw.ef_search คือตัวปรับระดับความแม่นยำ (recall) ซึ่งมีค่าเริ่มต้นอยู่ที่ 40:

BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id, body FROM chunks ORDER BY embedding <=> $1 LIMIT 8;
COMMIT;

ค่าที่สูงขึ้นจะทำให้การค้นหากราฟครอบคลุมมากขึ้น พบเพื่อนบ้านที่ดีขึ้น แต่ต้องแลกด้วยความหน่วง (latency) ที่เพิ่มขึ้น นี่เป็นการตั้งค่าระดับ session ดังนั้นคุณสามารถเพิ่มค่านี้สำหรับ query เฉพาะรายการได้โดยไม่ต้องแก้ไขตัว index

หาก query ไม่มีการใช้ index แผนการทำงาน (plan) จะแสดงให้เห็น:

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM chunks ORDER BY embedding <=> $1 LIMIT 8;

การสแกนแบบลำดับ (sequential scan) ในกรณีนี้มักเกิดจากพื้นที่จัดเก็บข้อมูล เวกเตอร์ขนาด 768 มิติมีขนาด 3,080 ไบต์ ซึ่งเกินกว่าที่ Postgres จะเก็บไว้ในแถวข้อมูลปกติ (inline) ได้ ค่าดังกล่าวจึงถูกย้ายไปเก็บในตาราง TOAST (พื้นที่จัดเก็บข้อมูลภายนอกสำหรับค่าที่มีขนาดใหญ่เกินไป) หมายเหตุของ pgvector ระบุว่าตัววางแผน (planner) ไม่ได้นับรวมพื้นที่จัดเก็บข้อมูลภายนอกนี้ในการประเมินต้นทุน ซึ่งอาจทำให้การสแกนแบบลำดับดูเหมือนมีต้นทุนต่ำกว่าความเป็นจริง ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; จะช่วยเก็บเวกเตอร์ไว้ในแถวข้อมูลปกติ โดยจะมีผลกับแถวที่เขียนขึ้นใหม่หลังจากเปลี่ยนการตั้งค่า ดังนั้นแถวข้อมูลเดิมจำเป็นต้องมีการเขียนตารางใหม่ (table rewrite)

การดึงข้อมูล: หนึ่งคำถาม สองสัญญาณ

Vector search ค้นหาข้อความที่มีความหมายตรงกับคำถาม แต่มีจุดอ่อนในการค้นหาค่าที่ตรงเป๊ะ เช่น หมายเลขชิ้นส่วน, รหัสข้อผิดพลาด หรือนามสกุล ในขณะที่ Keyword search ให้ผลลัพธ์ตรงกันข้าม ซึ่ง Postgres สามารถทำได้อยู่แล้ว คุณจึงควรนำทั้งสองวิธีมารวมไว้ในคำถามเดียวแทนที่จะต้องติดตั้งระบบที่สองเพิ่ม

Reciprocal rank fusion เป็นวิธีรวมผลลัพธ์ที่ง่ายและได้ผลดีที่สุด ผลลัพธ์แต่ละรายการจะได้รับ 1 / (60 + rank) จากทุกรายการที่ปรากฏ และนำคะแนนจากทั้งสองแหล่งมาบวกกัน วิธีนี้ไม่จำเป็นต้องทำ score normalisation เพราะเป็นการอ่านจากลำดับตำแหน่งแทนที่จะเป็นระยะห่าง

WITH semantic AS (
  SELECT id, row_number() OVER (ORDER BY distance) AS rank
  FROM (SELECT id, embedding <=> $1 AS distance
        FROM chunks ORDER BY embedding <=> $1 LIMIT 40) s
),
keyword AS (
  SELECT id, row_number() OVER (ORDER BY score DESC) AS rank
  FROM (SELECT c.id, ts_rank_cd(c.fts, q) AS score
        FROM chunks c, websearch_to_tsquery('english', $2) q
        WHERE c.fts @@ q
        ORDER BY score DESC LIMIT 40) k
)
SELECT c.id, c.body,
       coalesce(1.0 / (60 + s.rank), 0) + coalesce(1.0 / (60 + k.rank), 0) AS rrf
FROM (SELECT id FROM semantic UNION SELECT id FROM keyword) u
JOIN chunks c ON c.id = u.id
LEFT JOIN semantic s ON s.id = u.id
LEFT JOIN keyword  k ON k.id = u.id
ORDER BY rrf DESC
LIMIT 8;

$1 คือ embedding ของคำถามที่สร้างจากโมเดลเดียวกันโดยใช้ prefix search_query: ส่วน $2 คือคำถามในรูปแบบข้อความ ทั้งสองค่านี้จะถูกส่งมาจากแอปพลิเคชันของคุณ websearch_to_tsquery รองรับคำถามจริงจากผู้ใช้โดยไม่ติดปัญหาเรื่องเครื่องหมายวรรคตอน ซึ่ง to_tsquery ไม่สามารถทำได้ อีกเรื่องที่ควรทราบคือ การเพิ่มตัวกรอง WHERE ไว้เหนือการสแกนแบบ HNSW อาจทำให้ได้จำนวนแถวผลลัพธ์น้อยกว่าที่ระบุไว้ เนื่องจากระบบจะค้นหาจากดัชนีก่อนแล้วจึงค่อยนำตัวกรองไปใช้ภายหลัง SET hnsw.iterative_scan = relaxed_order; จะช่วยให้ pgvector สแกนข้อมูลต่อไปจนกว่าจะได้จำนวนแถวที่เพียงพอ

คุณจะทราบได้อย่างไรว่าการดึงข้อมูล (retrieval) มีประสิทธิภาพดีหรือไม่?

นี่คือขั้นตอนที่คู่มือ RAG เกือบทุกฉบับมองข้าม และเป็นขั้นตอนเดียวที่จะบอกคุณได้ว่าตัวเลือกอื่นๆ ที่คุณเลือกมานั้นช่วยได้จริงหรือไม่ คุณไม่จำเป็นต้องใช้เฟรมเวิร์กการประเมินผลที่ซับซ้อน สิ่งที่คุณต้องมีคือคำถาม 30 ข้อ และ ID ของ chunk ที่เป็นคำตอบของแต่ละข้อ

ให้เขียนคำถามเหล่านี้ด้วยตนเอง โดยนำคำถามที่ผู้ใช้งานถามเกี่ยวกับคลังข้อมูล (corpus) นี้จริงๆ มาทดสอบ รันคำถามแต่ละข้อ อ่านผลลัพธ์ที่ได้ แล้วบันทึก ID ของ chunk ที่ควรจะเป็นคำตอบที่ถูกต้อง คำถาม 30 ข้ออาจไม่เพียงพอที่จะแยกแยะความแตกต่างเล็กน้อย แต่จะช่วยให้คุณเห็นความแตกต่างที่สำคัญซึ่งส่งผลกระทบอย่างชัดเจน

CREATE TABLE gold (
  id        bigserial PRIMARY KEY,
  question  text   NOT NULL,
  chunk_id  bigint NOT NULL REFERENCES chunks(id),
  embedding vector(768) NOT NULL
);

ให้ทำ embedding คำถามแต่ละข้อด้วย prefix search_query: จัดเก็บไว้ จากนั้นจึงประเมินผลทั้งชุดในการ query เพียงครั้งเดียว

WITH hits AS (
  SELECT g.id,
         min(r.rank) FILTER (WHERE r.id = g.chunk_id) AS hit_rank
  FROM gold g
  CROSS JOIN LATERAL (
    SELECT top.id, row_number() OVER (ORDER BY top.distance) AS rank
    FROM (SELECT c.id, c.embedding <=> g.embedding AS distance
          FROM chunks c
          ORDER BY c.embedding <=> g.embedding
          LIMIT 10) top
  ) r
  GROUP BY g.id
)
SELECT count(*)         AS questions,
       count(hit_rank)  AS found_in_top_10,
       round(avg(coalesce(1.0 / hit_rank, 0)), 3) AS mrr
FROM hits;

found_in_top_10 หารด้วย questions คือค่า recall at 10 ซึ่งหมายถึงความถี่ที่คำตอบปรากฏอยู่ในหน้าต่างข้อมูลที่คุณส่งไปยังโมเดล ส่วน MRR (mean reciprocal rank) คือค่าเฉลี่ยของ 1 หารด้วยตำแหน่งของ chunk ที่ถูกต้อง โดยนับกรณีที่ไม่พบเป็นศูนย์ ดังนั้นค่านี้จึงให้คะแนนสูงกว่าหากจัดลำดับคำตอบไว้เป็นอันดับแรกแทนที่จะเป็นอันดับที่แปด ตัวเลขทั้งสองค่าจะเปลี่ยนแปลงเมื่อคุณปรับขนาด chunk, เปลี่ยนโมเดล embedding หรือเพิ่มการค้นหาด้วยคีย์เวิร์ด ซึ่งจะช่วยให้คุณเห็นทิศทางการเปลี่ยนแปลงของประสิทธิภาพได้

ให้ความสำคัญกับค่า recall at 10 เหนือสิ่งอื่นใด เพราะโมเดล generator ไม่สามารถใช้ข้อมูลจาก chunk ที่มันไม่ได้รับได้ หากค่า recall at 10 อยู่ที่ 0.9 แล้วคำตอบยังคงผิดพลาด แสดงว่าปัญหาอยู่ที่ prompt หรือตัวโมเดล ไม่ใช่ที่ขั้นตอนการดึงข้อมูล การแยกส่วนวิเคราะห์เช่นนี้จะช่วยประหยัดเวลาในการคาดเดาไปได้หลายวัน

ให้ตรวจสอบดัชนี (index) แยกต่างหาก การค้นหาแบบ approximate จะทำให้ค่า recall ลดลง และ pgvector จะแสดงให้คุณเห็นว่าลดลงมากน้อยเพียงใด โดยให้รัน query เดียวกันด้วยการค้นหาแบบ exact แล้วเปรียบเทียบ ID ที่ได้

BEGIN;
SET LOCAL enable_indexscan = off; -- use exact search
SELECT id FROM chunks ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;

หาก ID ที่ตรงกัน 9 ใน 10 รายการ แสดงว่า ef_search อยู่ในระดับที่เหมาะสม แต่หากตรงกันเพียง 4 ใน 10 รายการ แสดงว่าคุณควรเพิ่มค่าดังกล่าว

การจัดลำดับใหม่ (Reranking) และการสร้างข้อความ: จุดที่ API ทำเงิน

Reranker เป็นโมเดลอีกประเภทหนึ่ง โดยจะอ่านคำถามและข้อมูลหนึ่งส่วน (chunk) ไปพร้อมกันเพื่อประเมินคะแนนของคู่นั้น ซึ่งให้ผลลัพธ์ดีกว่าการเปรียบเทียบ embedding สองชุดที่คำนวณแยกกัน แต่โมเดลนี้ทำงานช้าเกินกว่าจะใช้กับข้อมูลทั้งหมดในคลังข้อมูล (corpus) นี่คือเหตุผลที่ต้องนำมาใช้ในขั้นตอนนี้ โดยโมเดลจะประมวลผลเฉพาะ 40 รายการที่ระบบดึงข้อมูล (retrieval) ส่งมาให้ ไม่ใช่ 100,000 ส่วนในตาราง ดังนั้น API สำหรับการจัดลำดับใหม่จึงคิดค่าบริการตามจำนวนคู่ข้อมูลสั้นๆ 40 คู่ต่อคำถาม เพื่อคัดกรองผลลัพธ์ที่ผิดพลาด (false positives) ออกก่อนที่จะเข้าสู่ขั้นตอนการสร้างข้อความที่มีต้นทุนสูง

การสร้างข้อความ (Generation) เป็นค่าใช้จ่ายที่เกิดขึ้นซ้ำๆ ซึ่งสามารถควบคุมได้ด้วยสองปัจจัยหลัก ประการแรกคือการส่งจำนวน chunk ให้น้อยลง โดยใช้ค่า recall ที่ 10 เพื่อทดสอบว่าสามารถลดจำนวน chunk ได้มากที่สุดเท่าใดโดยที่ยังคงได้คำตอบที่ถูกต้อง ประการที่สองคือการรักษาเนื้อหาช่วงต้นของ prompt ให้คงที่ในระดับไบต์ เพื่อให้ระบบ prompt cache ของผู้ให้บริการสามารถใช้งานได้ และวาง chunk ที่ดึงมาไว้หลังส่วนที่คงที่นั้น นอกจากนี้ควรทำแคชคำตอบที่สร้างเสร็จแล้วแยกตามคำถามด้วย เพราะ token ที่ประหยัดค่าใช้จ่ายที่สุดคือ token ที่คุณเคยสร้างไว้แล้วเมื่อสัปดาห์ก่อน

การกำหนดขนาดเซิร์ฟเวอร์และจุดที่ทรัพยากรไม่เพียงพอ

กฎการกำหนดขนาดทุกข้อในที่นี้คือสิ่งที่คุณต้องวัดค่าจริง ไม่ใช่การคาดคะเน

  • RAM คือข้อจำกัดหลัก: ขนาดของโมเดลที่โหลดอยู่ในหน่วยความจำจาก ollama ps บวกกับขนาดของดัชนี HNSW และ shared_buffers โดยต้องเหลือพื้นที่ว่างไว้สำหรับรองรับการเชื่อมต่อและ page cache
  • Disk ต้องการพื้นที่เป็น 2 เท่าของ pg_total_relation_size('chunks') เนื่องจากกระบวนการสร้างดัชนีใหม่ (rebuild) จะเก็บข้อมูลทั้งสองชุดไว้พร้อมกัน
  • CPU เป็นตัวกำหนดระยะเวลาในการทำ reindex โดยคำนวณจากจำนวนวินาทีต่อ chunk ที่คุณวัดได้ คูณด้วยจำนวน chunk ทั้งหมด
  • การทำ reindexing เกิดขึ้นบ่อยกว่าที่คุณคาดไว้ เพราะการเปลี่ยน embedding model จะทำให้ vector ทุกตัวที่จัดเก็บไว้เดิมใช้งานไม่ได้ทันที

การออกแบบนี้จะเริ่มไม่เพียงพอเมื่อถึงจุดที่คุณสามารถคาดการณ์ได้ เมื่อดัชนี HNSW ไม่สามารถโหลดลงใน RAM ที่คุณมีได้อีกต่อไป ค่าความหน่วง (latency) ของการสืบค้นจะกลายเป็นภาระของการอ่านข้อมูลจากดิสก์ (disk seeks) และไม่มีการตั้งค่าใดที่จะช่วยกู้คืนประสิทธิภาพได้ เมื่อตารางเดียวต้องรองรับผู้เช่า (tenants) จำนวนมากและทุกการสืบค้นต้องกรองข้อมูลตามผู้เช่า การทำ partitioning ตารางจะเป็นทางออก ซึ่งเป็นงานที่ต้องใช้ความพยายามจริง เมื่อการเขียนดัชนีและการสืบค้นของผู้ใช้แย่งทรัพยากรกันบนเซิร์ฟเวอร์เดียวกัน ให้ย้าย embedding worker ไปยังเซิร์ฟเวอร์ที่สองก่อนที่จะย้ายฐานข้อมูล จนกว่าจะถึงจุดนั้น Postgres ที่ทำงานร่วมกับ pgvector บน VPS ที่คุณเช่าอยู่ถือเป็นคำตอบที่ใช้งานจริงได้ และตัวเลขข้างต้นจะบอกคุณว่าขีดจำกัดนั้นอยู่ไกลเพียงใด

FAQ

ฉันสามารถรัน RAG pipeline บน VPS เครื่องเดียวได้หรือไม่ หรือจำเป็นต้องใช้ vector database?

VPS เครื่องเดียวเพียงพอสำหรับคลังข้อมูลขนาดหลายแสน chunks ที่มิติข้อมูล 768 มิติ ข้อมูลเวกเตอร์จำนวน 100,000 chunks จะใช้พื้นที่ 294 MiB รวมกับข้อความและดัชนี HNSW ซึ่งสามารถเก็บไว้ใน RAM ของแผนบริการทั่วไปได้ ข้อจำกัดอยู่ที่หน่วยความจำมากกว่าจำนวนแถว เนื่องจากกระบวนการค้นหาแบบ HNSW จะกระโดดไปมาในดัชนี ดังนั้น latency จะเพิ่มขึ้นเมื่อดัชนีไม่สามารถเก็บใน RAM ได้ทั้งหมด ให้เปรียบเทียบ pg_relation_size บนดัชนีกับ free -m แล้วคุณจะทราบสถานะของระบบ

ฉันจำเป็นต้องใช้ GPU เพื่อทำ embedding เอกสารหรือไม่?

ไม่จำเป็นหากคุณทำ embedding เพียงครั้งเดียวแล้วค่อยทำการสืบค้นในภายหลัง โมเดลขนาด 137 ล้านพารามิเตอร์ เช่น nomic-embed-text สามารถรันบน CPU ได้ และการประมวลผลคลังข้อมูลขนาดใหญ่ทั้งหมดอาจใช้เวลาหลายชั่วโมงซึ่งคุณสามารถรันข้ามคืนได้ GPU จะเริ่มมีความสำคัญเมื่อมีเอกสารเข้ามาอย่างต่อเนื่อง หรือเมื่อคุณต้องการรันการสร้างข้อความ (generation) บนเครื่องเดียวกัน ให้ทดสอบเวลาของหนึ่ง batch เทียบกับ /api/embed บนเซิร์ฟเวอร์ของคุณเองแล้วคูณด้วยจำนวน chunk ของคุณ เนื่องจากจำนวน vCPU ที่แตกต่างกันมากเกินกว่าที่ตัวเลขมาตรฐานจะอ้างอิงได้

ทำไมการสืบค้นเวกเตอร์ของฉันถึงใช้ sequential scan แทนที่จะใช้ดัชนี HNSW?

ให้อ่านแผนการทำงานด้วย EXPLAIN (ANALYZE, BUFFERS) สาเหตุที่พบบ่อยคือเรื่องการจัดเก็บข้อมูล: pgvector ระบุว่าตัววางแผน (planner) ไม่ได้นับรวมการจัดเก็บข้อมูลแบบ out-of-line ในการประเมินต้นทุน ซึ่งทำให้ serial scan ดูมีต้นทุนต่ำกว่าความเป็นจริง และเวกเตอร์ขนาด 768 มิติมีขนาด 3,080 ไบต์ จึงถูกเก็บไว้ในตาราง TOAST โดยค่าเริ่มต้น ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; จะช่วยเก็บแถวใหม่ไว้แบบ inline สาเหตุอื่นอีกสองประการคือ ตัวดำเนินการ (operator) ไม่ตรงกับดัชนี เนื่องจากดัชนีที่สร้างด้วย vector_cosine_ops จะถูกใช้โดย <=> เท่านั้น และการสืบค้นที่ไม่มี ORDER BY ... LIMIT เนื่องจากดัชนีแบบประมาณการ (approximate index) จะรองรับเฉพาะการสืบค้นแบบ ordered nearest neighbour เท่านั้น

ฉันจะทราบได้อย่างไรว่าการดึงข้อมูลของฉันมีประสิทธิภาพดีหรือไม่?

ให้สร้างชุดข้อมูลทองคำ (gold set) จำนวน 30 คำถาม โดยจับคู่แต่ละคำถามกับ id ของ chunk ที่เป็นคำตอบ และจัดเก็บ embedding ของคำถามนั้นไว้ด้วยกัน จากนั้นให้วัดค่า recall at 10 ซึ่งคือความถี่ที่ chunk ที่ถูกต้องปรากฏอยู่ใน 10 อันดับแรก และค่า MRR ซึ่งให้คะแนนตามลำดับความสำคัญ ตัวเลขสองค่านี้คือสิ่งที่บอกคุณว่าการเปลี่ยนแปลงขนาด chunk, โมเดล embedding หรือ rank fusion นั้นส่งผลดีหรือไม่ หากไม่มีตัวเลขเหล่านี้ คุณก็เพียงแค่เปลี่ยนการตั้งค่าไปเรื่อยๆ โดยอาศัยความรู้สึกจากคำตอบเพียงไม่กี่ข้อเท่านั้น