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

วิธีรัน Vector Database บน VPS ให้คุ้มค่าและมีประสิทธิภาพ

เปรียบเทียบการใช้งาน pgvector, Qdrant และ Chroma บน VPS เครื่องเดียวเพื่อลด Latency พร้อมคำนวณการใช้ RAM ให้เหมาะสมกับจำนวน Vector และมิติข้อมูลที่คุณต้องรองรับในการใช้งานจริง

ต้นทุนที่แท้จริงของการรัน vector database บน VPS

การรัน vector database บน VPS (virtual private server) ช่วยขจัดปัญหาที่ผู้ให้บริการแบบ managed service พยายามขายโซลูชันเพื่อแก้ไข แอปพลิเคชันและดัชนีของคุณจะอยู่บนเครื่องเดียวกัน ดังนั้นคำขอค้นหาจึงผ่าน loopback socket แทนที่จะผ่านเครือข่าย สิ่งที่เหลืออยู่คือต้นทุนที่เป็นปัจจัยหลักมาโดยตลอด นั่นคือการแปลงข้อความเป็นเวกเตอร์ นอกจากนี้ยังมีอีกสองปัจจัยที่ตามมา คือเวลาที่ใช้ในการสร้างดัชนี และปริมาณ RAM ที่ดัชนีต้องใช้ตลอดระยะเวลาที่ให้บริการ

ปัจจัยเหล่านี้เปลี่ยนความสำคัญในการตัดสินใจ เรื่องภูมิภาค (region) และเวลาในการรับส่งข้อมูล (round trip) ของ endpoint จะไม่ใช่ประเด็นที่คุณต้องกังวลอีกต่อไป สิ่งที่คุณต้องให้ความสำคัญคือผลคูณของจำนวนเวกเตอร์, มิติข้อมูล (dimensions) และขนาด 4 bytes เพราะสิ่งนี้จะเป็นตัวตัดสินว่าดัชนีจะสามารถเก็บไว้ในหน่วยความจำที่คุณเช่าใช้งานรายเดือนได้หรือไม่

ระยะเวลาในระดับมิลลิวินาทีบนเซิร์ฟเวอร์เครื่องเดียวไปอยู่ที่ไหนบ้าง

ลองติดตามการทำงานของ query ความคล้ายคลึง (similarity query) ผ่าน stack ที่คุณโฮสต์เอง

  1. ข้อความ query จะถูกแปลงเป็น vector โดย embedding model หากรันบน CPU จะใช้เวลาตั้งแต่หลักสิบถึงหลักร้อยมิลลิวินาทีสำหรับข้อความสั้นๆ แต่หากรันบน GPU จะใช้เวลาเพียงหลักหน่วยเท่านั้น
  2. vector จะถูกส่งไปยัง store ซึ่งหากส่งผ่าน loopback TCP หรือ Unix domain socket จะใช้เวลาเพียงเศษเสี้ยวของมิลลิวินาที
  3. store จะทำการค้นหาดัชนี (index) และส่งคืนแถวข้อมูลที่ใกล้เคียงที่สุดกลับมา
  4. โค้ดของคุณจะอ่านข้อความที่ตรงกันและประกอบร่างเป็น prompt

ขั้นตอนที่ 1 มักเป็นส่วนที่ใช้เวลามากที่สุดในรายการนี้ ส่วนขั้นตอนที่ 2 คือจุดที่ผู้ให้บริการแบบ hosted แข่งขันกัน แต่สำหรับการรันบนเครื่องเดียว ขั้นตอนนี้แทบไม่มีนัยสำคัญ อย่าเดาส่วนแบ่งเวลาด้วยตัวเอง ให้วัดเวลาทั้งสองฝั่งบนเซิร์ฟเวอร์ของคุณเอง

curl http://127.0.0.1:11434/api/embed -s -o /dev/null \
  -w 'embed: %{time_total}s\n' \
  -d '{"model": "nomic-embed-text", "input": "how do I rotate the api key"}'

จากนั้นให้รัน \timing on ใน psql ก่อนที่จะเริ่ม query ค้นหา หากคำสั่งแรกแสดงผล embed: 0.184312s และ psql ตอบกลับมาเป็น Time: 4.201 ms แสดงว่าการปรับแต่ง index ไม่ใช่สิ่งที่ควรทำ เพราะปัญหาความหน่วง (latency) ของคุณอยู่ที่ตัว embedding model การ รัน embedding model ในเครื่องด้วย Ollama จะทำให้ขั้นตอนที่ 1 ไปรันบน CPU เดียวกับขั้นตอนที่ 2 และ 3 ส่งผลให้ทั้งสองส่วนต้องแย่งทรัพยากร core และ RAM กัน ลูปการนำเข้าข้อมูล (ingest) และการดึงข้อมูล (retrieval) ที่ครอบอยู่บน store นี้มีอธิบายไว้ใน คู่มือ RAG pipeline ที่โฮสต์เอง RAG ย่อมาจาก retrieval augmented generation คือการที่คุณค้นหาเอกสารของคุณเองแล้วนำผลลัพธ์ที่ตรงที่สุดไปวางใน prompt

เมื่อมีเวกเตอร์ไม่เกินหนึ่งแสนรายการ ให้สแกนทั้งหมด

การสแกนแบบละเอียดจะเปรียบเทียบ query กับทุกเวกเตอร์ที่จัดเก็บไว้ ซึ่งจะได้ค่า recall ที่สมบูรณ์แบบโดยนิยาม วิธีนี้ไม่จำเป็นต้องมี index หรือขั้นตอนการสร้าง และไม่มีปัญหาข้อมูลล้าหลังกว่าข้อมูลจริง

การคำนวณจะบอกคุณเองว่าเมื่อใดที่วิธีนี้เริ่มไม่เหมาะสม การสแกนจะอ่านข้อมูล n * d * 4 ไบต์ต่อหนึ่ง query โดยที่ n คือจำนวนเวกเตอร์ และ d คือมิติของเวกเตอร์ สำหรับเวกเตอร์ 100,000 รายการที่ 768 มิติ จะเท่ากับ 307 MB ต่อ query ซึ่ง CPU สมัยใหม่สามารถประมวลผลได้ในเวลาเพียงไม่กี่สิบมิลลิวินาที แต่หากเป็นเวกเตอร์ 5 ล้านรายการ จะเท่ากับ 15 GB ต่อ query ซึ่งไม่ถือว่าเป็น query ที่ใช้งานได้จริงอีกต่อไป

ดังนั้น ให้จัดเก็บเวกเตอร์ไว้ใน SQLite และทำการเปรียบเทียบด้วย NumPy

import sqlite3, numpy as np

db = sqlite3.connect("docs.db")
db.execute("CREATE TABLE IF NOT EXISTS docs (id INTEGER PRIMARY KEY, body TEXT, vec BLOB)")

def add(body, vec):
    v = np.asarray(vec, dtype=np.float32)
    v /= np.linalg.norm(v)
    db.execute("INSERT INTO docs (body, vec) VALUES (?, ?)", (body, v.tobytes()))
    db.commit()

def search(query_vec, k=5):
    rows = db.execute("SELECT id, body, vec FROM docs").fetchall()
    mat = np.frombuffer(b"".join(r[2] for r in rows), dtype=np.float32).reshape(len(rows), -1)
    q = np.asarray(query_vec, dtype=np.float32)
    q /= np.linalg.norm(q)
    scores = mat @ q
    return [(rows[i][0], rows[i][1], float(scores[i])) for i in np.argsort(-scores)[:k]]

ข้อมูลทั้งสองฝั่งถูกปรับสเกลให้มีความยาวเป็นหนึ่งหน่วย ดังนั้น dot product จึงเท่ากับ cosine similarity และคะแนนที่สูงกว่าหมายถึงความใกล้เคียงที่มากกว่า ให้โหลด mat เพียงครั้งเดียวตอนเริ่มต้นระบบแทนที่จะโหลดทุกครั้งที่มี query เพื่อให้การอ่านจาก SQLite ไม่ขวางเส้นทางการประมวลผลหลัก (hot path)

ควรทดสอบเวลาบนเครื่องของคุณเองก่อนที่จะปฏิเสธวิธีนี้

import time
t = time.perf_counter()
search(q)
print(f"{(time.perf_counter() - t) * 1000:.1f} ms")

ข้อจำกัดตามความเป็นจริงคือ วิธีนี้เป็นกระบวนการเดียวที่เก็บเมทริกซ์ทั้งหมดไว้ใน RAM และไม่มีการกรองข้อมูล metadata รวมถึงไม่มีกลไกจัดการการเขียนข้อมูลพร้อมกัน หากปัจจัยเหล่านี้คือสาเหตุที่คุณไม่พอใจ ให้เปลี่ยนไปใช้วิธีอื่น ตัว SQLite เองเป็นระบบจัดเก็บข้อมูลฝั่งเซิร์ฟเวอร์ที่จริงจัง ซึ่งอธิบายไว้ใน คู่มือการใช้งาน SQLite ในระดับ production และหากภาระงานจริงของคุณคือการสแกนคอลัมน์แทนที่จะเป็นการดึงข้อมูลรายแถว การเปรียบเทียบระหว่าง DuckDB และ SQLite จะเป็นบทความที่มีประโยชน์มากกว่า

pgvector สำหรับผู้ที่ใช้งาน Postgres อยู่แล้ว

หากแอปพลิเคชันของคุณใช้งานฐานข้อมูล Postgres อยู่แล้ว pgvector จะเพิ่มภาระในการดูแลรักษาน้อยที่สุด เนื่องจากเป็นส่วนขยาย (extension) ไม่ใช่บริการแยกต่างหาก เวกเตอร์จะถูกจัดเก็บไว้ในตารางปกติเคียงคู่กับแถวข้อมูลที่อธิบาย ทำให้การค้นหาแบบกรองข้อมูลทำได้โดยใช้คำสั่ง WHERE แทนที่จะต้องจัดการระบบที่สองให้ซิงค์ข้อมูลกัน

Ubuntu 24.04 มีแพ็กเกจนี้อยู่ในส่วน universe

sudo apt update
sudo apt install -y postgresql-16-pgvector
sudo -u postgres psql -d yourdb -c 'CREATE EXTENSION vector;'

แพ็กเกจดังกล่าวคือ pgvector เวอร์ชัน 0.6.0 ณ เดือนสิงหาคม 2026 ซึ่งถือว่าล้าหลังกว่าเวอร์ชันล่าสุด โดยเฉพาะการสแกนดัชนีแบบวนซ้ำ (iterative index scans) ที่จำเป็นต้องใช้เวอร์ชัน 0.8 ดังนั้นควรติดตั้งจาก repository ของโครงการ PostgreSQL โดยตรง

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

ให้แทนที่ 17 ด้วยเวอร์ชันหลักของเซิร์ฟเวอร์คุณ ซึ่งสามารถตรวจสอบได้ด้วยคำสั่ง sudo -u postgres psql -tAc 'SHOW server_version' หากติดตั้งแพ็กเกจส่วนขยายที่สร้างมาสำหรับเวอร์ชันหลักที่ไม่ตรงกัน CREATE EXTENSION จะล้มเหลว เนื่องจาก Postgres จะค้นหาเฉพาะในไดเรกทอรี share ของเวอร์ชันที่กำลังทำงานอยู่เท่านั้น:

ERROR:  could not open extension control file "/usr/share/postgresql/16/extension/vector.control": No such file or directory

โครงสร้างข้อมูล (schema) เป็น SQL ปกติที่มีการเพิ่มชนิดข้อมูลใหม่เข้ามาหนึ่งประเภท

CREATE TABLE chunks (
  id        bigserial PRIMARY KEY,
  doc_id    bigint NOT NULL,
  body      text   NOT NULL,
  embedding vector(768)
);

SELECT id, body FROM chunks
ORDER BY embedding <=> '[0.013, -0.021, 0.004]'
LIMIT 5;

<=> คือระยะห่างแบบโคไซน์ (cosine distance), <-> คือระยะห่างแบบ L2 (Euclidean) และ <#> คือผลคูณภายในเชิงลบ (negative inner product) ให้เลือกใช้ตัวที่โมเดล embedding ของคุณถูกฝึกมา หากเลือกผิดจะไม่มีข้อความแจ้งเตือนข้อผิดพลาด แต่ผลลัพธ์ที่ได้จะมีคุณภาพต่ำลงโดยที่คุณไม่ทราบสาเหตุ

หากไม่มีการสร้างดัชนี คำสั่งค้นหาจะเป็นการค้นหาแบบละเอียด (exact search) ในทุกแถวข้อมูล ซึ่งเปรียบเสมือนการค้นหาแบบ brute force ในเวอร์ชันของ Postgres และให้ผลลัพธ์ที่แม่นยำที่สุด การเพิ่มค่า max_parallel_workers_per_gather จะช่วยให้มีการใช้จำนวนคอร์ประมวลผลมากขึ้น ควรดำเนินการในขั้นตอนนี้ก่อนแล้วจึงค่อยสร้างดัชนีในภายหลัง เพื่อให้คุณมีเกณฑ์มาตรฐานความแม่นยำ (recall baseline) ไว้สำหรับวัดประสิทธิภาพของดัชนีที่จะสร้างขึ้น

Qdrant เมื่อดัชนีมีขนาดใหญ่เกินกว่าฐานข้อมูลทั่วไป

Qdrant เป็น vector store เฉพาะทางที่เขียนด้วยภาษา Rust ซึ่งมีความเหมาะสมเมื่อดัชนีมีขนาดใหญ่จนคุณไม่ต้องการให้กระบวนการสร้างดัชนีแย่งทรัพยากรกับฐานข้อมูล Postgres ของแอปพลิเคชัน หรือเมื่อคุณต้องการฟีเจอร์การกรอง payload และการทำ quantisation ซึ่ง pgvector ไม่มีให้

docker run -d --name qdrant \
  -p 127.0.0.1:6333:6333 -p 127.0.0.1:6334:6334 \
  -e QDRANT__SERVICE__API_KEY="$(openssl rand -hex 32)" \
  -v "$(pwd)/qdrant_storage:/qdrant/storage:z" \
  qdrant/qdrant

พอร์ต 6333 ให้บริการ REST (representational state transfer) API และแดชบอร์ดที่ /dashboard ส่วนพอร์ต 6334 ให้บริการ gRPC มีรายละเอียดสำคัญ 2 ประการสำหรับ VPS ที่เปิดสู่สาธารณะ เอกสารของ Qdrant ระบุว่าบริการจะทำงานโดยค่าเริ่มต้น "โดยไม่มีการเข้ารหัสหรือการยืนยันตัวตน" และ -p 6333:6333 จากคู่มือเริ่มต้นใช้งานฉบับย่อจะผูกบริการเข้ากับทุกอินเทอร์เฟซ ซึ่ง Docker จะเปิดพอร์ตนี้ผ่านกฎ ufw เนื่องจาก Docker เขียนกฎการส่งต่อข้อมูลของตนเอง คุณควรผูกบริการเข้ากับ 127.0.0.1 และตั้งค่า API key หากอินสแตนซ์ Qdrant สามารถเข้าถึงได้ผ่าน IP สาธารณะโดยไม่มีคีย์ ข้อมูลเอกสารของคุณจะถูกเปิดเผยต่อสาธารณะทันที

curl -s http://127.0.0.1:6333/collections -H "api-key: $QDRANT_API_KEY"

การตอบสนองที่ปกติจะมีลักษณะเหมือน {"result":{"collections":[]},"status":"ok","time":0.00002} หากได้รับ {"status":{"error":"Unauthorized"}} กลับมา หมายความว่าชื่อ header หรือคีย์ไม่ถูกต้อง และหากไม่ได้รับการตอบสนองใดๆ เลย หมายความว่าคอนเทนเนอร์ไม่ได้ทำงานหรือถูกผูกไว้ที่อื่น การพิจารณาว่าคอนเทนเนอร์นี้เหมาะสมกับเซิร์ฟเวอร์ของคุณหรือไม่เป็นคำถามพื้นฐานของบริการแบบ stateful ดังนั้น การเปรียบเทียบระหว่าง Docker กับฐานข้อมูลบนโฮสต์ จึงนำมาใช้ในกรณีนี้ได้เช่นกัน

Chroma และวัตถุประสงค์การใช้งาน

Chroma เป็นเส้นทางที่สั้นที่สุดในการเปลี่ยนจากจุดเริ่มต้นไปสู่การสร้างตัวอย่างระบบ Retrieval ที่ใช้งานได้จริง

pip install chromadb
chroma run --path /srv/chroma

บริการนี้จะทำงานบนพอร์ต 8000 และ chromadb.HttpClient(host="localhost", port=8000) จะเชื่อมต่อเข้ากับพอร์ตดังกล่าว Chroma มาพร้อมกับฟังก์ชัน embedding เริ่มต้น ทำให้การสร้างต้นแบบครั้งแรกไม่จำเป็นต้องมีเซิร์ฟเวอร์โมเดลแยกต่างหาก

ควรพิจารณาข้อแลกเปลี่ยนนี้อย่างตรงไปตรงมา Chroma ใช้งานได้สะดวกเพราะช่วยซ่อนการตัดสินใจที่คู่มือนี้กำลังกล่าวถึง เช่น การเลือก distance metric และปริมาณ RAM ที่ต้องใช้รองรับผลลัพธ์ ซึ่งเป็นสิ่งที่เหมาะสมสำหรับช่วงการทำต้นแบบ แต่ไม่เหมาะสมสำหรับระบบงานจริงที่คุณต้องคอยเฝ้าระวัง หากข้อมูลของคุณจัดเก็บอยู่ใน Postgres อยู่แล้ว การย้ายข้อมูลไปยัง Chroma จะเป็นการเพิ่มกระบวนการทำงานและปัญหาเรื่องการซิงโครไนซ์ข้อมูลโดยไม่จำเป็น เพื่อแก้ปัญหาที่ pgvector ไม่มีอยู่แล้ว

ดัชนีต้องการ RAM เท่าใด

ให้เริ่มคำนวณจาก raw vectors เนื่องจากเป็นค่าพื้นฐานที่ไม่สามารถปรับลดลงได้ด้วยการจูนค่าใดๆ

bytes = number_of_vectors * dimensions * 4

ข้อมูล 32-bit float หนึ่งค่าต่อหนึ่งมิติใช้พื้นที่ 4 ไบต์ เอกสารการวางแผนความจุของ Qdrant ได้เพิ่มตัวคูณ 1.5 สำหรับ metadata และ temporary segments ที่เกิดขึ้นระหว่างการทำ optimisation:

memory_size = number_of_vectors * vector_dimension * 4 bytes * 1.5

นี่คือสูตรคำนวณสำหรับเวกเตอร์จำนวนหนึ่งล้านรายการ ตามจำนวนมิติที่โมเดล embedding ทั่วไปสร้างขึ้น:

ChartRAM for 1 million vectors, by embedding dimension
The data behind this chart
[
  {
    "label": "384 dims",
    "raw_gib": 1.43,
    "with_overhead_gib": 2.15
  },
  {
    "label": "768 dims",
    "raw_gib": 2.86,
    "with_overhead_gib": 4.29
  },
  {
    "label": "1024 dims",
    "raw_gib": 3.81,
    "with_overhead_gib": 5.72
  },
  {
    "label": "1536 dims",
    "raw_gib": 5.72,
    "with_overhead_gib": 8.58
  },
  {
    "label": "3072 dims",
    "raw_gib": 11.44,
    "with_overhead_gib": 17.17
  }
]

ค่าเหล่านี้เป็นผลลัพธ์จากสูตรในหน่วย gibibytes (GiB) ไม่ใช่การวัดค่าจริง ให้ถือว่าค่านี้คือขนาดของพื้นที่ว่างในหน่วยความจำที่คุณต้องเตรียมไว้ โมเดลขนาด 768 มิติ เช่น nomic-embed-text สำหรับข้อมูลหนึ่งล้าน chunks ต้องการพื้นที่ประมาณ 4.29 GiB ซึ่งสามารถรันบนแผน 8 GB ได้โดยยังมีพื้นที่เหลือสำหรับ Postgres แต่ถ้าเป็นข้อมูลชุดเดียวกันที่ 3072 มิติ จะต้องการพื้นที่ 17.17 GiB ซึ่งไม่สามารถรันได้

ปัจจัยสำคัญอยู่ที่คอลัมน์แรกของตาราง ไม่ใช่คอลัมน์สุดท้าย การลดจำนวนมิติลงครึ่งหนึ่งจะช่วยลดจำนวนไบต์ที่ต้องใช้ลงครึ่งหนึ่งตลอดไป โมเดลขนาด 768 มิติที่อาจได้คะแนนบน leaderboard ต่ำกว่าเล็กน้อย มักจะเป็นทางเลือกทางวิศวกรรมที่ดีกว่าเมื่อใช้งานบน VPS สำหรับประเภทข้อมูล halfvec ของ pgvector จะจัดเก็บข้อมูลแบบ 16-bit floats ซึ่งช่วยลดจำนวนไบต์ลงได้อีกครึ่งหนึ่ง และสามารถทำดัชนีผ่าน expression ได้ดังนี้:

CREATE INDEX ON chunks USING hnsw ((embedding::halfvec(768)) halfvec_cosine_ops);

ข้อจำกัดหนึ่งที่ควรทราบก่อนเลือกโมเดลคือ ประเภทข้อมูล vector ของ pgvector รองรับได้สูงสุด 16,000 มิติ แต่ดัชนี HNSW และ IVFFlat รองรับได้เพียง 2,000 มิติเท่านั้น หากเกินกว่านั้น คุณจะต้องทำดัชนีโดยใช้การ cast เป็น halfvec ซึ่งรองรับได้สูงสุด 4,000 มิติ หรือไม่เช่นนั้นก็ไม่สามารถทำดัชนีได้เลย

ค่า m และ ef_construction ส่งผลอย่างไรต่อเวลาในการสร้างดัชนี

HNSW (hierarchical navigable small world) คือดัชนีที่ทั้ง pgvector และ Qdrant เลือกใช้ โดยมีโครงสร้างเป็นกราฟแบบหลายชั้น เวกเตอร์แต่ละตัวเปรียบเสมือนโหนดที่มีการเชื่อมโยงไปยังโหนดใกล้เคียง การค้นหาจะกระโดดไปตามลิงก์เหล่านี้เพื่อเข้าใกล้ผลลัพธ์ที่ต้องการ แทนที่จะต้องอ่านข้อมูลทั้งหมด

m คือจำนวนลิงก์ที่แต่ละโหนดจะเก็บไว้ เอกสารของ Faiss ระบุว่าหน่วยความจำที่ใช้สำหรับ HNSW คือ (d * 4 + m * 2 * 4) ไบต์ต่อเวกเตอร์ และแนะนำให้กำหนดค่า m ให้อยู่ระหว่าง 4 ถึง 64 หากลองรันที่ 768 มิติกับเวกเตอร์จำนวนหนึ่งล้านรายการ

ChartCost of raising m at 768 dimensions, 1 million vectors
The data behind this chart
[
  {
    "label": "m = 8",
    "link_bytes_per_vector": 64,
    "total_gib": 2.92
  },
  {
    "label": "m = 16 (default)",
    "link_bytes_per_vector": 128,
    "total_gib": 2.98
  },
  {
    "label": "m = 32",
    "link_bytes_per_vector": 256,
    "total_gib": 3.1
  },
  {
    "label": "m = 64",
    "link_bytes_per_vector": 512,
    "total_gib": 3.34
  }
]

สิ่งที่น่าประหลาดใจคือขนาดของความแตกต่างที่เกิดขึ้น การเปลี่ยนจากค่าเริ่มต้น m = 16 ไปเป็น m = 64 จะเพิ่มขนาดข้อมูลลิงก์ขึ้นอีก 512 ไบต์ต่อเวกเตอร์ เมื่อเทียบกับข้อมูลเวกเตอร์ขนาด 3072 ไบต์ ทำให้ขนาดรวมเปลี่ยนจาก 2.98 GiB เป็น 3.34 GiB ซึ่งคิดเป็นประมาณ 12 เปอร์เซ็นต์ ที่มิติข้อมูลระดับนี้ m ไม่ใช่ส่วนที่กินหน่วยความจำหลัก แต่เป็นตัวเวกเตอร์เองต่างหาก

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

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

maintenance_work_mem คือการตั้งค่าที่ตัดสินว่าการสร้างดัชนีจะใช้เวลาเป็นนาทีหรือเป็นชั่วโมง เนื่องจาก pgvector จะประกอบกราฟในหน่วยความจำหากมีพื้นที่เพียงพอ และเมื่อพื้นที่ไม่เพียงพอ pgvector จะแจ้งเตือนดังนี้:

NOTICE:  hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL:  Building will take significantly more time.
HINT:  Increase maintenance_work_mem to speed up builds.

ข้อความแจ้งเตือนนั้นคือบรรทัดที่มีประโยชน์ที่สุดที่ pgvector แสดงออกมา หมายความว่าการสร้างดัชนีได้ตกลงไปอยู่ในขั้นตอนที่ช้ากว่ามาก ให้ยกเลิกการทำงาน ปรับค่าการตั้งค่าให้สูงกว่าจำนวน RAM ที่คุณคำนวณไว้ข้างต้น แล้วเริ่มใหม่ ให้เฝ้าดูความคืบหน้าจากเซสชันที่สอง:

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

HNSW จะรายงานค่า initializing และตามด้วย loading tuples หากการสร้างดัชนีค้างอยู่ที่เปอร์เซ็นต์ต่ำเป็นเวลานานบนดิสก์ที่ไม่มีการใช้งาน นั่นคือปัญหา maintenance_work_mem ไม่ใช่คิวรีที่ค้าง

มีสองข้อเท็จจริงที่ควรวางแผนรับมือ: README ของ pgvector ระบุว่า HNSW "มีเวลาสร้างที่ช้ากว่าและใช้หน่วยความจำมากกว่า" เมื่อเทียบกับ IVFFlat แต่แลกมาด้วยความสมดุลระหว่างความเร็วและความแม่นยำ (recall) ที่ดีกว่า นอกจากนี้ HNSW สามารถสร้างบนตารางที่ว่างเปล่าได้ ในขณะที่ IVFFlat จำเป็นต้องรัน k-means บนข้อมูลตัวอย่างก่อน ดังนั้นการสร้าง IVFFlat บนตารางที่ว่างเปล่าจะให้ค่า recall ที่ต่ำ ใน schema ใหม่ HNSW จึงเป็นตัวเลือกที่คุณสามารถสร้างเตรียมไว้ล่วงหน้าได้

ef_search: พารามิเตอร์ที่คุณปรับแต่งได้หลังการ build

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

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

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

ข้อควรระวังประการหนึ่งคือ ef_search ทำงานร่วมกับเงื่อนไข WHERE ที่มีการคัดกรองสูงได้ไม่ดีนัก เนื่องจาก index จะส่งคืนจำนวน candidate ที่คงที่และตัวกรองจะถูกนำมาใช้ในภายหลัง ตัวกรองที่ปฏิเสธแถวข้อมูลส่วนใหญ่อาจทำให้คุณเหลือผลลัพธ์น้อยกว่า LIMIT รายการ ทั้งที่ยังมีแถวที่ตรงกับเงื่อนไขอยู่ในตาราง pgvector 0.8 แก้ปัญหานี้ด้วยการสแกนแบบวนซ้ำ:

SET hnsw.iterative_scan = relaxed_order;

จากนั้น index จะถูกสแกนซ้ำเพื่อหา candidate เพิ่มเติมจนกว่าจะครบตามจำนวนที่กำหนด สูงสุดถึง hnsw.max_scan_tuples ซึ่งมีค่าเริ่มต้นที่ 20000 strict_order จะรักษาลำดับระยะทางที่แม่นยำไว้และมีต้นทุนการประมวลผลสูงกว่า นี่คือฟีเจอร์ที่ไม่มีในแพ็กเกจ Ubuntu 0.6.0 และการที่ข้อมูลหายไปเมื่อใช้ตัวกรองคือวิธีที่คุณจะทราบว่าคุณกำลังใช้เวอร์ชันที่ขาดฟีเจอร์นี้อยู่

เหตุผลที่ดัชนีต้องอยู่ใน RAM

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

เอกสารของ Qdrant ได้สรุปประเด็นนี้ไว้ว่า "หากคุณเก็บเวกเตอร์ใน RAM ได้เพียงครึ่งเดียว ความหน่วงในการค้นหาจะเพิ่มขึ้นประมาณสองเท่า" จงวางแผนโดยยึดตามประโยคนี้

ในกรณีที่ดัชนีไม่สามารถเก็บใน RAM ได้ทั้งหมด ทุกทางเลือกคือการแลกเปลี่ยนที่คุณต้องตัดสินใจอย่างตั้งใจ

  • ทำ Memory-map ให้กับเวกเตอร์ เพื่อให้ระบบปฏิบัติการแคชหน้าข้อมูลที่ถูกเรียกใช้บ่อย (hot pages) ไว้ และเก็บส่วนที่เหลือไว้บนดิสก์ วิธีนี้จำเป็นต้องใช้ที่เก็บข้อมูลแบบ NVMe ที่มีความเร็วสูงรองรับเพื่อให้ประสิทธิภาพอยู่ในระดับที่ยอมรับได้
  • ทำ Quantisation โดยเก็บแต่ละมิติเป็น 1 ไบต์แทนที่จะเป็น 4 ไบต์ วิธีนี้ช่วยลดขนาดเวกเตอร์ลงได้ 4 เท่า โดยแลกกับค่า recall ที่ลดลงเล็กน้อยและวัดผลได้
  • แปลงข้อมูลเป็น halfvec ใน pgvector ซึ่งช่วยลดขนาดไบต์ลงครึ่งหนึ่งโดยมีการสูญเสียค่า recall น้อยกว่าการทำ quantisation แบบ 1 ไบต์
  • ใช้โมเดลที่มีขนาดเล็กลงในการทำ Embedding นี่เป็นวิธีแก้ไขที่ประหยัดที่สุดและเป็นวิธีที่คนมักมองข้าม เพราะหมายถึงการต้องทำ Embedding ข้อมูลชุดเดิมใหม่ทั้งหมด

รูปแบบความล้มเหลวและข้อความที่คุณจะพบ

could not open extension control file. แพ็กเกจ pgvector สำหรับเวอร์ชันหลักของ Postgres ที่กำลังทำงานอยู่ไม่ได้ถูกติดตั้งไว้ ให้ตรวจสอบเวอร์ชันด้วย sudo -u postgres psql -tAc 'SHOW server_version' และติดตั้ง postgresql-NN-pgvector ที่ตรงกัน

ERROR: expected 768 dimensions, not 1536. ประเภทของคอลัมน์และโมเดลไม่ตรงกัน คุณได้เปลี่ยนโมเดลการทำ embedding แต่ไม่ได้ทำ embedding ใหม่ ไม่มีวิธีแก้ไขเพียงบางส่วน เนื่องจากเวกเตอร์จากโมเดลที่ต่างกันไม่สามารถนำมาเปรียบเทียบกันได้เลย ดังนั้นคุณจึงต้องสร้างข้อมูลใหม่ในทุกแถว

การคิวรีทำงานช้าและ EXPLAIN แสดงผลเป็น sequential scan. คลาสตัวดำเนินการของดัชนี (index operator class) และตัวดำเนินการที่ใช้ในคิวรีไม่ตรงกัน vector_cosine_ops รองรับเฉพาะ <=> เท่านั้น ให้รัน EXPLAIN ANALYZE บนคิวรีนั้นและมองหา Index Scan using ... on chunks หากคุณพบ Seq Scan on chunks แทน ให้สร้างดัชนีใหม่โดยใช้คลาสตัวดำเนินการที่ตรงกับตัวดำเนินการที่คุณใช้คิวรีจริง

จำนวนแถวน้อยกว่า LIMIT และมีการใช้คำสั่ง WHERE. นี่คือกับดักของการกรองข้อมูลที่กล่าวถึงข้างต้น ให้เพิ่มค่า hnsw.ef_search หรืออัปเกรดไปใช้ pgvector 0.8 แล้วตั้งค่า hnsw.iterative_scan

การสร้างดัชนีสิ้นสุดลงโดยที่กระบวนการหายไปและไม่มีข้อผิดพลาดใน psql. การตั้งค่า maintenance_work_mem ไว้สูงเกือบเท่าหน่วยความจำทั้งหมดของเครื่อง ในขณะที่ shared_buffers และแอปพลิเคชันของคุณก็ต้องการหน่วยความจำเช่นกัน ส่งผลให้ kernel สั่งฆ่ากระบวนการ (out-of-memory killer) ให้ตรวจสอบ sudo dmesg -T | grep -i 'killed process' ซึ่งจะแสดงบรรทัดที่ระบุถึง postgres ให้ลดค่าการตั้งค่าดังกล่าวลง หรือสร้างดัชนีบนแผนการใช้งานที่มีขนาดใหญ่ขึ้นแล้วจึงกู้คืนข้อมูล (restore dump) กลับมา

การเลือกใช้งาน

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

สำหรับจำนวนเวกเตอร์ที่น้อยกว่าหนึ่งแสนรายการ ให้ทดสอบการสแกนแบบ brute-force ก่อนที่จะติดตั้งซอฟต์แวร์ใดๆ เพิ่มเติม การค้นหาแบบละเอียด (exhaustive search) ที่ให้ผลลัพธ์แม่นยำ 100% โดยไม่ต้องผ่านขั้นตอนการสร้างดัชนี ไม่ถือเป็นข้อจำกัดสำหรับข้อมูลขนาดเท่านี้ แต่นี่คือแนวทางที่ถูกต้อง การเลือกใช้ดัชนีแบบประมาณการ (approximate index) ในกรณีนี้หมายถึงการเพิ่มภาระในการปรับแต่งค่าและการใช้หน่วยความจำ RAM เพื่อแลกกับเวลาเพียงไม่กี่มิลลิวินาทีที่คุณอาจไม่ได้เสียไปตั้งแต่แรก

FAQ

ฉันจำเป็นต้องใช้ฐานข้อมูลเวกเตอร์โดยเฉพาะ หรือ Postgres ก็เพียงพอแล้ว?

หากข้อมูลของคุณอยู่ใน Postgres อยู่แล้ว pgvector นั้นเพียงพอต่อการใช้งานมากกว่าที่การเปรียบเทียบส่วนใหญ่แนะนำ โดยจะจัดเก็บเวกเตอร์ไว้ในคอลัมน์ปกติ ดังนั้นการค้นหาแบบกรองข้อมูลจึงเป็นเพียงคำสั่ง WHERE และการสำรองข้อมูลที่มีอยู่เดิมก็ครอบคลุมถึงดัชนีด้วย ให้ย้ายไปใช้ระบบจัดเก็บข้อมูลเฉพาะทางอย่าง Qdrant ก็ต่อเมื่อภาระงานด้านเวกเตอร์ต้องการขีดจำกัดหน่วยความจำของตัวเอง หรือเมื่อคุณต้องการการกรอง payload และการทำ quantization ที่ pgvector ไม่มีให้

VPS หนึ่งเครื่องสามารถเก็บเวกเตอร์ได้กี่รายการ?

ให้คำนวณแทนการคาดเดาโดยใช้ number_of_vectors * dimensions * 4 bytes * 1.5 เวกเตอร์ขนาด 768 มิติ จำนวน 1 ล้านรายการ จะใช้พื้นที่ประมาณ 4.3 GiB ดังนั้นแผนบริการขนาด 8 GB จึงรองรับได้และยังมีพื้นที่เหลือสำหรับ Postgres ส่วนเวกเตอร์ขนาด 3072 มิติ จำนวน 1 ล้านรายการ จะใช้พื้นที่ประมาณ 17 GiB และจำเป็นต้องใช้แผนบริการที่ใหญ่กว่ามาก ปัจจัยที่ส่งผลมากที่สุดคือจำนวนมิติของโมเดล embedding ของคุณ ดังนั้นควรเลือกโมเดลโดยคำนึงถึงค่าใช้จ่ายด้านหน่วยความจำด้วย

ทำไมการค้นหาเวกเตอร์ของฉันถึงช้าทั้งที่ดัชนีอยู่บนเครื่องเดียวกัน?

ปัญหาไม่ได้อยู่ที่เครือข่ายเมื่ออยู่บนเครื่องเดียวกัน ดังนั้นให้ตรวจสอบสองปัจจัยหลัก ประการแรก ให้จับเวลาการเรียกใช้ embedding แยกต่างหาก เพราะการสร้าง query vector บน CPU มักใช้เวลานานกว่าการค้นหาจริง ประการที่สอง ตรวจสอบว่าดัชนีอยู่ใน RAM หรือไม่ การค้นหาแบบ HNSW จะกระโดดไปมาในกราฟแบบสุ่ม ดังนั้นเมื่อกราฟถูกเขียนลงดิสก์ การกระโดดแต่ละครั้งอาจกลายเป็นการอ่านดิสก์ และคำแนะนำของ Qdrant ระบุว่าการลดจำนวนเวกเตอร์ที่เก็บใน RAM ลงครึ่งหนึ่งจะทำให้ latency ในการค้นหาเพิ่มขึ้นประมาณสองเท่า

ฉันควรสร้างดัชนี HNSW หรือไม่?

ไม่จำเป็นหากมีเวกเตอร์ไม่ถึงหนึ่งแสนรายการ การสแกนแบบละเอียด (exhaustive scan) จะอ่านข้อมูล n * d * 4 ไบต์ต่อการค้นหา ซึ่งเท่ากับ 307 MB สำหรับเวกเตอร์ 768 มิติ จำนวน 100,000 รายการ และ CPU สมัยใหม่สามารถประมวลผลข้อมูลจำนวนนี้ได้ในเวลาเพียงไม่กี่สิบมิลลิวินาทีโดยมีความแม่นยำสมบูรณ์และไม่ต้องมีขั้นตอนการสร้างดัชนี ให้วัดผลการสแกนบนฮาร์ดแวร์ของคุณเองก่อน แล้วค่อยสร้างดัชนีเมื่อเวลาที่วัดได้ช้าเกินไปจริงๆ ไม่ใช่เพราะบทความเปรียบเทียบแนะนำให้ทำ

การเพิ่มค่า m ส่งผลกระทบอย่างไรบ้าง?

ส่งผลต่อเวลาในการสร้างดัชนีและเวลาในการแทรกข้อมูลมากกว่าหน่วยความจำมาก ที่ขนาด 768 มิติ การปรับจากค่าเริ่มต้น m = 16 เป็น m = 64 จะเพิ่มลิงก์กราฟ 512 ไบต์ต่อเวกเตอร์ เทียบกับข้อมูลเวกเตอร์ 3072 ไบต์ ดังนั้นหน่วยความจำรวมจะเพิ่มขึ้นประมาณ 12 เปอร์เซ็นต์ อย่างไรก็ตาม ทุกการแทรกข้อมูลจะต้องค้นหาและเชื่อมโยงเพื่อนบ้านเพิ่มขึ้นถึง 4 เท่า ให้ปรับแต่ง ef_search ก่อน เนื่องจากไม่มีค่าใช้จ่ายในการปรับเปลี่ยนและไม่ต้องสร้างดัชนีใหม่

#vector-database#rag#pgvector#qdrant#self-hosting