ในระบบการเงิน คุณแทบไม่เคยได้ข้อมูลที่ตรงเป๊ะ ผู้ใช้พิมพ์ชื่อร้านผิด ธนาคารตัดช่องว่างทิ้ง เลขอ้างอิงมี prefix ที่ไม่เหมือนกัน OCR อ่าน “0” เป็น “O” เป้าหมายของโมดูลนี้คือ จับคู่รายการธุรกรรมหนึ่งเข้ากับ “ตัวที่ใกล้ที่สุด” ในฐานข้อมูลที่มีเป็นล้านแถว ได้อย่างเร็วและแม่น
ปัญหา: ข้อมูลไม่เคยตรงเป๊ะ
การใช้ WHERE name = $1 ใช้ไม่ได้เพราะ “บจก. กาแฟดี” กับ “บริษัท กาแฟดี จำกัด” คือร้านเดียวกัน การใช้ LIKE '%...%' ก็พังเพราะมัน full-scan ทั้งตารางและจัดอันดับความใกล้เคียงไม่ได้ เราต้องการตัววัด “ความคล้าย” ที่มี index รองรับ สองเครื่องมือหลักคือ:
- Lexical / trigram - คล้ายกันที่ระดับตัวอักษร เหมาะกับชื่อ เลขอ้างอิง เลขบัญชี ที่พิมพ์ผิดเล็กน้อย
- Semantic / vector - คล้ายกันที่ระดับความหมาย เหมาะกับ รายละเอียดธุรกรรมที่เขียนคนละแบบแต่หมายถึงสิ่งเดียวกัน
pg_trgm และ trigram index
trigram คือการตัดสตริงเป็นชุดตัวอักษรสามตัวที่ซ้อนกัน เช่น coffee → {" c"," co","cof","off","ffe","fee","ee "} สองสตริงที่ใช้ trigram ร่วมกันมาก ก็ถือว่าใกล้กัน PostgreSQL มี extension pg_trgm ที่ให้ตัวดำเนินการ % (similar) และฟังก์ชัน similarity() พร้อม GIN index ที่ทำให้ค้นเร็วบนข้อมูลล้านแถว
1-- เปิด extension ครั้งเดียวต่อฐานข้อมูล2CREATE EXTENSION IF NOT EXISTS pg_trgm;34-- ตาราง merchant ที่เราจะจับคู่เข้าไป5CREATE TABLE IF NOT EXISTS merchants (6 id BIGSERIAL PRIMARY KEY,7 display_name TEXT NOT NULL,8 ref_code TEXT NOT NULL9);1011-- GIN index บน trigram: ทำให้ % และ similarity() ใช้ index ได้12-- gin_trgm_ops สำคัญมาก ถ้าไม่ใส่ index จะไม่ถูกใช้13CREATE INDEX IF NOT EXISTS merchants_name_trgm14 ON merchants USING GIN (display_name gin_trgm_ops);1516CREATE INDEX IF NOT EXISTS merchants_ref_trgm17 ON merchants USING GIN (ref_code gin_trgm_ops);เมื่อมี index แล้ว query ค้นหาตัวที่ใกล้ที่สุดจะหน้าตาแบบนี้ จุดสำคัญคือเรียงด้วย distance operator <-> (1 - similarity) เพราะ PostgreSQL ใช้ GIN/GiST index เร่ง ORDER BY แบบ nearest-neighbour ได้
1-- ตั้ง threshold ขั้นต่ำ (0.0 - 1.0) ของระดับ session2SET pg_trgm.similarity_threshold = 0.3;34SELECT5 id,6 display_name,7 similarity(display_name, $1) AS score8FROM merchants9WHERE display_name % $1 -- ใช้ GIN index คัดผู้สมัครก่อน10ORDER BY display_name <-> $1 -- เรียงจากใกล้สุด11LIMIT 5;Schema: Prisma และ Drizzle
ORM ส่วนใหญ่ยังไม่รู้จัก GIN trigram index โดยตรง วิธีที่เสถียรที่สุดคือประกาศคอลัมน์ใน ORM แล้วสร้าง index ผ่าน raw migration เพื่อคุม index type ได้เอง
1model Merchant {2 id BigInt @id @default(autoincrement())3 displayName String @map("display_name")4 refCode String @map("ref_code")56 @@map("merchants")7}จากนั้นเพิ่มไฟล์ migration ของ Prisma ด้วย prisma migrate dev --create-only แล้วใส่ SQL CREATE EXTENSION + CREATE INDEX ... USING GIN เองตามด้านบน สำหรับ Drizzle คุณคุม index ได้ในไฟล์ schema เลย:
1import { bigserial, index, pgTable, text, sql } from "drizzle-orm/pg-core";23export const merchants = pgTable(4 "merchants",5 {6 id: bigserial("id", { mode: "bigint" }).primaryKey(),7 displayName: text("display_name").notNull(),8 refCode: text("ref_code").notNull(),9 },10 (t) => ({11 // index แบบ GIN + gin_trgm_ops ผ่าน raw SQL12 nameTrgm: index("merchants_name_trgm")13 .using("gin", sql`${t.displayName} gin_trgm_ops`),14 }),15);ค้นหาจาก TypeScript
ฝั่งแอป เราห่อ query ด้วย parameterized SQL เสมอเพื่อกัน SQL injection และคืนค่า score กลับมาให้ caller ตัดสินใจได้ว่าจะ auto-match หรือส่งให้คนรีวิว
1import { Pool } from "pg";23const pool = new Pool({ connectionString: process.env.DATABASE_URL });45export type MatchResult = {6 id: bigint;7 displayName: string;8 score: number;9};1011// auto = มั่นใจพอจะจับคู่อัตโนมัติ, review = ต้องให้คนยืนยัน12const AUTO_THRESHOLD = 0.55;13const REVIEW_THRESHOLD = 0.3;1415export async function matchMerchant(query: string) {16 const client = await pool.connect();17 try {18 await client.query("SET LOCAL pg_trgm.similarity_threshold = 0.3");19 const { rows } = await client.query<MatchResult>(20 `SELECT id, display_name AS "displayName",21 similarity(display_name, $1) AS score22 FROM merchants23 WHERE display_name % $124 ORDER BY display_name <-> $125 LIMIT 5`,26 [query.trim()],27 );2829 const best = rows[0];30 if (!best) return { decision: "no_match" as const, candidates: rows };31 if (best.score >= AUTO_THRESHOLD)32 return { decision: "auto" as const, match: best, candidates: rows };33 if (best.score >= REVIEW_THRESHOLD)34 return { decision: "review" as const, match: best, candidates: rows };35 return { decision: "no_match" as const, candidates: rows };36 } finally {37 client.release();38 }39}Vector similarity (pgvector)
เมื่อความคล้ายอยู่ที่ “ความหมาย” ไม่ใช่ตัวอักษร (เช่น “ค่ากาแฟ” กับ “ซื้อเครื่องดื่ม”) เราฝัง embedding แล้วเก็บใน pgvector index ด้วย HNSW เพื่อค้นแบบ approximate nearest neighbour ที่เร็วมากบนล้านเวกเตอร์
1CREATE EXTENSION IF NOT EXISTS vector;23ALTER TABLE merchants4 ADD COLUMN IF NOT EXISTS embedding vector(1536);56-- HNSW: เร็วและแม่นกว่า ivfflat สำหรับงาน online7-- vector_cosine_ops คู่กับ <=> (cosine distance)8CREATE INDEX IF NOT EXISTS merchants_embedding_hnsw9 ON merchants USING hnsw (embedding vector_cosine_ops);1import os2import asyncpg3from openai import AsyncOpenAI45client = AsyncOpenAI()67async def embed(text: str) -> list[float]:8 resp = await client.embeddings.create(9 model="text-embedding-3-small",10 input=text,11 )12 return resp.data[0].embedding1314async def match_by_meaning(pool: asyncpg.Pool, query: str, k: int = 5):15 vec = await embed(query)16 # ส่ง vector เป็น string literal ที่ pgvector เข้าใจ: '[0.1,0.2,...]'17 literal = "[" + ",".join(f"{x:.6f}" for x in vec) + "]"18 rows = await pool.fetch(19 """20 SELECT id, display_name,21 1 - (embedding <=> $1::vector) AS score22 FROM merchants23 WHERE embedding IS NOT NULL24 ORDER BY embedding <=> $1::vector25 LIMIT $226 """,27 literal, k,28 )29 return [dict(r) for r in rows]Hybrid: รวม trigram + vector
ของจริงมักใช้ทั้งสองอย่างรวมกัน: ใช้ trigram จับชื่อ/เลขอ้างอิง ใช้ vector จับเจตนา แล้วรวมคะแนนแบบถ่วงน้ำหนัก เทคนิคที่นิยมคือ Reciprocal Rank Fusion (RRF) ซึ่งทนต่อสเกลคะแนนที่ต่างกันของสองวิธี
1-- รวมอันดับจาก trigram และ vector ด้วย Reciprocal Rank Fusion2WITH lexical AS (3 SELECT id, row_number() OVER (ORDER BY display_name <-> $1) AS rnk4 FROM merchants5 WHERE display_name % $16 LIMIT 507),8semantic AS (9 SELECT id, row_number() OVER (ORDER BY embedding <=> $2::vector) AS rnk10 FROM merchants11 WHERE embedding IS NOT NULL12 LIMIT 5013)14SELECT15 m.id,16 m.display_name,17 COALESCE(1.0 / (60 + l.rnk), 0) + COALESCE(1.0 / (60 + s.rnk), 0) AS rrf18FROM merchants m19LEFT JOIN lexical l ON l.id = m.id20LEFT JOIN semantic s ON s.id = m.id21WHERE l.id IS NOT NULL OR s.id IS NOT NULL22ORDER BY rrf DESC23LIMIT 10;เช็กลิสต์ production
- normalize ข้อความก่อน match เสมอ: ตัดช่องว่างซ้ำ, lowercase, แปลงเลขไทยเป็นอารบิก
- วัด threshold จากชุดข้อมูลจริงที่มี label ไม่ใช่เดา ติดตาม precision/recall เป็นรายสัปดาห์
- ทุก auto-match ต้องเก็บ score และเวอร์ชันโมเดลไว้ เพื่อ audit ย้อนหลังได้
- เติม embedding แบบ batch ใน background ไม่ใช่ตอน request (ดูโมดูล OCR pipeline)
- มีโซน review เสมอ - ในงานเงิน การ “ไม่ตัดสิน” ปลอดภัยกว่าตัดสินผิด
สรุปสำคัญ
- ใช้ GIN + gin_trgm_ops เพื่อให้ตัวดำเนินการ % และ similarity() ใช้ index ได้จริง
- ตั้งสองเกณฑ์ (auto / review) เสมอ การมีโซน review คือสิ่งที่กันเงินหาย
- trigram จับความคล้ายระดับตัวอักษร, vector จับความคล้ายระดับความหมาย, hybrid รวมด้วย RRF
ควิซท้ายบท
01ทำไมต้องสร้าง index แบบ GIN ด้วย gin_trgm_ops เมื่อใช้ pg_trgm จับคู่ชื่อ
02ตัวดำเนินการ % ใน pg_trgm ตัดสินว่าสองสตริง 'คล้ายพอ' จากค่าใด
03เมื่อความคล้ายอยู่ที่ 'ความหมาย' (เช่น "ค่ากาแฟ" กับ "ซื้อเครื่องดื่ม") ควรใช้วิธีใด
04ในการจับคู่แบบ hybrid ที่รวมอันดับจาก trigram และ vector ด้วย Reciprocal Rank Fusion (RRF) ค่าคงที่ k (เช่น 60) มีหน้าที่อะไร