pgvector: ทำ Semantic Search ใน PostgreSQL

ถ้าระบบของคุณใช้ PostgreSQL อยู่แล้ว ไม่จำเป็นต้องเพิ่ม vector database อีกตัวเพื่อทำ semantic search หรือ RAG เพราะมี extension ชื่อ pgvector ที่เพิ่มชนิดข้อมูลเวกเตอร์ operator สำหรับวัดระยะ และ index สำหรับค้นหาแบบเร็วให้ PostgreSQL ข้อดีคือข้อมูลเวกเตอร์อยู่ตารางเดียวกับข้อมูลธุรกิจ JOIN ได้ กรองด้วย WHERE ได้ ใช้ transaction และ backup ชุดเดิมได้ บทความนี้พาทำตั้งแต่ติดตั้ง สร้างคอลัมน์ บันทึกข้อมูล สร้าง index ไปจนถึง query และเรียกจาก Yii2
ติดตั้งและเปิดใช้ extension
pgvector เป็นโปรเจกต์โอเพนซอร์สที่ github.com/pgvector/pgvector วิธีติดตั้งมีหลายทาง
- Docker ใช้ image ที่มี pgvector ติดตั้งไว้แล้ว เช่น
pgvector/pgvectorโดยเลือก tag ให้ตรงกับเวอร์ชัน PostgreSQL ที่ใช้ - Linux หลาย distribution มีแพ็กเกจให้ติดตั้งผ่าน package manager โดยชื่อแพ็กเกจผูกกับเวอร์ชัน PostgreSQL
- Managed database ผู้ให้บริการคลาวด์จำนวนมากรองรับ pgvector แล้ว ตรวจในเอกสารของผู้ให้บริการว่าเปิดใช้ได้หรือไม่
- Build จาก source ตามขั้นตอนใน README ของโปรเจกต์
# docker-compose.yml (ตัวอย่างสำหรับ dev)
services:
db:
image: pgvector/pgvector:pg16
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
ports:
- "5432:5432"
หลังติดตั้งไฟล์ของ extension แล้ว ต้องเปิดใช้ในแต่ละฐานข้อมูลด้วยคำสั่งนี้ (ต้องมีสิทธิ์เพียงพอ) สังเกตว่าชื่อ extension คือ vector ไม่ใช่ pgvector
CREATE EXTENSION IF NOT EXISTS vector;
สร้างตารางและคอลัมน์ vector
ชนิดข้อมูล vector(N) โดย N คือจำนวนมิติ ซึ่ง ต้องตรงกับ embedding model ที่ใช้ ถ้าโมเดลคืนเวกเตอร์ 768 มิติ ก็ต้องเป็น vector(768) ถ้าใส่จำนวนไม่ตรง PostgreSQL จะ error ตอน INSERT
CREATE TABLE doc_chunk (
id bigserial PRIMARY KEY,
document_id int NOT NULL REFERENCES document(id) ON DELETE CASCADE,
content text NOT NULL,
embed_model varchar(100) NOT NULL,
embedding vector(768) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
คอลัมน์ embed_model ไม่บังคับ แต่ช่วยได้มากเมื่อวันหนึ่งเปลี่ยนโมเดล เพราะเวกเตอร์จากคนละโมเดลเอามาเทียบกันไม่ได้ ต้อง embed ใหม่ทั้งหมด
ใน Yii2 เขียนเป็น migration ได้แบบนี้
<?php
use yii\db\Migration;
class m261016_000000_create_doc_chunk extends Migration
{
public function safeUp()
{
$this->execute('CREATE EXTENSION IF NOT EXISTS vector');
$this->createTable('{{%doc_chunk}}', [
'id' => $this->bigPrimaryKey(),
'document_id' => $this->integer()->notNull(),
'content' => $this->text()->notNull(),
'embed_model' => $this->string(100)->notNull(),
'embedding' => 'vector(768) NOT NULL',
'created_at' => $this->timestamp()->notNull()->defaultExpression('now()'),
]);
$this->addForeignKey('fk_doc_chunk_document', '{{%doc_chunk}}', 'document_id',
'{{%document}}', 'id', 'CASCADE');
$this->execute('CREATE INDEX idx_doc_chunk_embedding ON {{%doc_chunk}}
USING hnsw (embedding vector_cosine_ops)');
}
public function safeDown()
{
$this->dropTable('{{%doc_chunk}}');
}
}
บันทึกเวกเตอร์ลงตาราง
pgvector รับค่าในรูปข้อความ '[0.1,0.2,0.3]' เราจึงแปลงอาร์เรย์ PHP เป็น string แล้วส่งผ่าน parameter ตามปกติ ไม่ต้องต่อ SQL เอง
<?php
function toPgVector(array $vec): string
{
return '[' . implode(',', array_map(
static fn($v) => (string)(float)$v,
$vec
)) . ']';
}
Yii::$app->db->createCommand()->insert('{{%doc_chunk}}', [
'document_id' => $documentId,
'content' => $chunkText,
'embed_model' => getenv('EMBEDDING_MODEL'),
'embedding' => toPgVector($embedding), // ได้จาก embedding API
])->execute();
PostgreSQL จะแปลงข้อความเป็นชนิด vector ให้อัตโนมัติเมื่อ INSERT ลงคอลัมน์ vector ถ้ามีเอกสารจำนวนมาก ให้ทำการ embed และ insert ใน console command หรือ queue job ไม่ใช่ใน web request
Query ด้วย operator <=>
pgvector มี operator วัดระยะหลายตัว ที่ใช้บ่อยคือ
| Operator | ความหมาย | operator class สำหรับ index |
|---|---|---|
<-> | L2 distance (ระยะทางแบบยุคลิด) | vector_l2_ops |
<#> | negative inner product | vector_ip_ops |
<=> | cosine distance | vector_cosine_ops |
สำหรับ embedding ข้อความ นิยมใช้ cosine distance <=> ซึ่ง ค่ายิ่งน้อยยิ่งใกล้ จึงเรียงแบบ ASC และถ้าอยากได้ similarity ให้คำนวณ 1 - distance
SELECT id, content,
1 - (embedding <=> '[0.011,-0.024,...]'::vector) AS similarity
FROM doc_chunk
ORDER BY embedding <=> '[0.011,-0.024,...]'::vector
LIMIT 5;
(ในตัวอย่าง ... แทนตัวเลขที่เหลือ เวกเตอร์จริงต้องมีครบตามจำนวนมิติ)
เรียกจาก Yii2 ด้วย parameter binding และกรองเงื่อนไขธุรกิจร่วมด้วย
<?php
$qvec = toPgVector($questionEmbedding);
$rows = Yii::$app->db->createCommand(
'SELECT c.id, c.content, d.title,
1 - (c.embedding <=> CAST(:q AS vector)) AS similarity
FROM {{%doc_chunk}} c
JOIN {{%document}} d ON d.id = c.document_id
WHERE d.is_published = true
ORDER BY c.embedding <=> CAST(:q AS vector)
LIMIT :k',
[':q' => $qvec, ':k' => 5]
)->queryAll();
ใช้ CAST(:q AS vector) แทน :q::vector เพราะเครื่องหมาย :: ติดกับชื่อ parameter อาจทำให้การแยก parameter สับสนได้ในบางไดรเวอร์
Index: HNSW และ IVFFlat ในเชิงแนวคิด
ถ้าไม่มี index การค้นแบบ ORDER BY ระยะทางจะเป็น exact search คือคำนวณระยะกับทุกแถว ผลแม่นยำ 100% และเร็วพอสำหรับข้อมูลไม่มาก แต่จะช้าลงตามจำนวนแถว pgvector จึงมี index แบบ approximate nearest neighbor (ANN) สองแบบ ที่ยอมแลกความแม่นยำเล็กน้อยกับความเร็วที่มากขึ้นมาก
HNSW
สร้างโครงสร้างกราฟหลายชั้น คล้ายแผนที่ที่มีทั้งทางด่วนและซอยย่อย การค้นจะกระโดดในชั้นบนเพื่อเข้าใกล้เป้าหมายเร็ว ๆ แล้วค่อยละเอียดในชั้นล่าง จุดเด่นคือ query เร็วและ recall ดี สร้าง index ได้ตั้งแต่ตารางยังว่าง แลกกับการใช้หน่วยความจำมากกว่าและสร้าง index นานกว่า
CREATE INDEX ON doc_chunk USING hnsw (embedding vector_cosine_ops);
-- ปรับความแม่นยำตอน query: ค่ามากขึ้น แม่นขึ้นแต่ช้าลง
SET hnsw.ef_search = 100;
IVFFlat
แบ่งเวกเตอร์เป็นกลุ่ม (list) ตอนค้นจะดูเฉพาะกลุ่มที่ใกล้คำค้น สร้างเร็วกว่าและใช้หน่วยความจำน้อยกว่า แต่ ควรสร้างหลังจากมีข้อมูลแล้ว เพราะการแบ่งกลุ่มคำนวณจากข้อมูลที่มีอยู่ตอนสร้าง
CREATE INDEX ON doc_chunk USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
-- จำนวนกลุ่มที่ค้นต่อ query: มากขึ้น แม่นขึ้นแต่ช้าลง
SET ivfflat.probes = 10;
ข้อควรรู้เรื่อง index
- operator class ต้องตรงกับ operator สร้างด้วย
vector_cosine_opsต้อง query ด้วย<=>ไม่อย่างนั้น index จะไม่ถูกใช้ - index ถูกใช้กับรูปแบบ
ORDER BY embedding <=> ... LIMIT nตรวจด้วยEXPLAINทุกครั้ง - เมื่อมี WHERE ร่วมด้วย ANN index กรองหลังค้น อาจได้ผลน้อยกว่า LIMIT ถ้าเงื่อนไขตัดแถวออกเยอะ ให้ศึกษาตัวเลือกเรื่อง filtering ใน README ของ pgvector
- มีข้อจำกัดเรื่องจำนวนมิติสูงสุดที่ index ได้ ตรวจในเอกสารของ pgvector ก่อนเลือกโมเดล
- ข้อมูลหลักพันแถว exact search อาจเร็วพอแล้ว ไม่ต้องรีบสร้าง index
ข้อควรระวังเมื่อใช้งานจริง
เมื่อระบบค้นหาทำงานได้แล้ว ยังมีเรื่องในชีวิตจริงที่ต้องวางแผนตั้งแต่ต้น
- เนื้อหาเปลี่ยน เวกเตอร์ต้องเปลี่ยนตาม เมื่อแก้หรือลบเอกสาร ต้องลบ chunk เดิมแล้ว embed ใหม่ วิธีที่ง่ายคือทำใน
afterSaveของ ActiveRecord โดยส่งงานเข้า queue แทนการเรียก embedding API ใน request เดียวกัน - เปลี่ยน embedding model ต้อง embed ใหม่ทั้งหมด และถ้าจำนวนมิติเปลี่ยน ต้องเปลี่ยนชนิดคอลัมน์และสร้าง index ใหม่ คอลัมน์
embed_modelช่วยให้ทำทีละส่วนแล้วสลับได้อย่างปลอดภัย - ขนาดข้อมูล เวกเตอร์หลายร้อยมิติต่อแถวกินพื้นที่มากกว่าข้อความทั่วไปพอสมควร รวมถึง index ด้วย ควรประเมินพื้นที่ดิสก์ หน่วยความจำ และเวลา backup ล่วงหน้า
- ใช้ร่วมกับ full-text search PostgreSQL มี
tsvectorอยู่แล้ว สามารถค้นทั้งสองแบบในฐานข้อมูลเดียวแล้วรวมผล (hybrid) เพื่อจับทั้งความหมายและคำตรงตัวอย่างรหัสสินค้า - สิทธิ์การเข้าถึง ใส่เงื่อนไขสิทธิ์ใน WHERE ทุกครั้ง โดยเฉพาะเมื่อใช้ผลค้นหาไปทำ RAG เพราะเอกสารที่ค้นเจอจะถูกส่งให้โมเดลอ่านและอาจไปโผล่ในคำตอบ
สรุป
- pgvector ทำให้ PostgreSQL เก็บและค้นเวกเตอร์ได้ เปิดใช้ด้วย
CREATE EXTENSION vector; - คอลัมน์
vector(N)ต้องมี N ตรงกับ embedding model และควรเก็บชื่อโมเดลคู่กันไว้ - ใช้
<=>สำหรับ cosine distance เรียง ASC และส่งเวกเตอร์ผ่าน parameter binding - HNSW query เร็วและแม่น ส่วน IVFFlat สร้างเร็วกว่าแต่ควรสร้างหลังมีข้อมูล ทั้งสองต้องใช้ operator class ให้ตรงกับ operator
ลองทำ
รัน PostgreSQL ที่มี pgvector ด้วย Docker สร้างตาราง item ที่มีคอลัมน์ embedding vector(3) ใส่ข้อมูลของเล่น 5 แถว เช่น '[1,0,0]', '[0.9,0.1,0]', '[0,1,0]' แล้ว query หาแถวที่ใกล้ '[1,0,0]' ที่สุดด้วย <=> พร้อมแสดง similarity จากนั้นสร้าง HNSW index แล้วรัน EXPLAIN ดูแผน (ถ้าข้อมูลน้อย planner อาจเลือก seq scan ลองใช้ SET enable_seqscan = off; ในการทดลองเพื่อดูว่า index ถูกใช้ได้)
ความคิดเห็น (0)
ยังไม่มีความคิดเห็น — เริ่มคุยเป็นคนแรก ถามสิ่งที่สงสัย หรือแชร์ประสบการณ์ของคุณ