0Pricing
PostgreSQL Performance & Query Optimization · บทเรียน

การออกแบบคอลัมน์ tsvector และดัชนี GIN

คำนวณเอกสารสำหรับค้นหาไว้ล่วงหน้าและสร้างดัชนี เพื่อให้คำค้นเต็มข้อความใช้เวลาต่ำกว่ามิลลิวินาทีแม้มีข้อมูลมาก

การออกแบบคอลัมน์ tsvector และดัชนี GIN เป็นบทเรียน PostgreSQL Performance & Query Optimization ฟรีบน CoddyKit นี่คือบทเรียนที่ 1 จากทั้งหมด 4 บทเรียน คุณสามารถอ่านบทเรียนทั้งหมดด้านล่างฟรี — จากนั้นลองปฏิบัติด้วยตัวคุณเองในเบราว์เซอร์พร้อมตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7 บทเรียนนี้เป็นส่วนหนึ่งของเส้นทางการเรียน PostgreSQL Performance & Query Optimization และความก้าวหน้าของคุณจะซิงค์ข้ามเว็บและแอป CoddyKit คอร์ส PostgreSQL Performance & Query Optimization มีบทเรียนทั้งหมด 4 บทเรียน

บางส่วนของบทเรียนนี้ยังไม่ได้รับการแปล และแสดงเป็นภาษาอังกฤษ

Why a Precomputed tsvector

PostgreSQL full-text search compares a tsvector (the searchable document) against a tsquery (the search terms). The naive approach calls to_tsvector() on a raw text column at query time.

That works, but it has two costs at scale:

  • CPU per row: parsing and stemming text on every scan is expensive.
  • No usable index unless the index expression exactly matches the query expression.

The fix is to precompute the document once and store it, then index it. This lesson shows how to design that column and the GIN index so full-text queries stay sub-millisecond even on millions of rows.

The Naive Query (and Its Trap)

Here is the pattern most people start with: store plain text, and build the tsvector on the fly.

The query below works correctly, but on a large table it triggers a sequential scan and re-parses body for every row. Each call to to_tsvector stems and normalizes the full document text.

The lesson's goal is to eliminate this per-row work entirely.

SELECT id, title
FROM articles
WHERE to_tsvector('english', body) @@ to_tsquery('english', 'index & scan');

Option A: A Stored Generated Column

The cleanest modern design (PostgreSQL 12+) is a stored generated column. PostgreSQL computes the tsvector automatically whenever the row changes, so the document is always consistent with the source text.

Two rules to remember:

  • The generation expression must be IMMUTABLE, which is why you pass the regconfig as a literal ('english') rather than relying on a session setting.
  • Use coalesce() so a NULL field doesn't make the whole document NULL.
ALTER TABLE articles
  ADD COLUMN search_doc tsvector
  GENERATED ALWAYS AS (
    to_tsvector('english', coalesce(title, '') || ' ' || coalesce(body, ''))
  ) STORED;

Weighting Fields with setweight

Not every field deserves equal importance. A match in the title usually matters more than a match deep in the body. setweight() tags lexemes with a label A, B, C, or D (A is highest).

These labels later let ts_rank score title matches above body matches. Bake the weighting into the generated column so it is computed once, not at query time.

ALTER TABLE articles
  ADD COLUMN search_doc tsvector
  GENERATED ALWAYS AS (
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(body,  '')), 'B')
  ) STORED;

Building the GIN Index

A stored tsvector is still useless without an index. The right index type for full-text search is GIN (Generalized Inverted Index). GIN stores one entry per distinct lexeme pointing to the rows that contain it, which is exactly what @@ matching needs.

Because the column already holds a tsvector, the index is a plain column index, no expression required:

CREATE INDEX idx_articles_search_doc
  ON articles
  USING GIN (search_doc);

Querying the Indexed Column

Now the query references the stored column directly. The planner can use the GIN index because the expression in the WHERE clause (search_doc) matches the indexed expression exactly.

No to_tsvector() per row, no sequential scan. Run EXPLAIN ANALYZE and you should see a Bitmap Index Scan on idx_articles_search_doc.

SELECT id, title
FROM articles
WHERE search_doc @@ to_tsquery('english', 'index & scan')
ORDER BY ts_rank(search_doc, to_tsquery('english', 'index & scan')) DESC
LIMIT 20;

GIN vs GiST: Picking the Right One

PostgreSQL supports two index types for tsvector. Choose deliberately:

  • GIN: faster lookups, the default choice for search. Slightly larger and slower to build/update. Best when reads dominate.
  • GiST: smaller and cheaper to update, but lossy, so it rechecks candidate rows and is slower for queries. Useful for very write-heavy or constantly-churning data.

For most search workloads, where you query far more than you write, GIN wins. Reach for GiST only when index-update cost is your bottleneck.

Tuning GIN: fastupdate and gin_pending_list_limit

GIN indexes use a pending list to batch inserts (fastupdate = on by default). This speeds up writes, but a large pending list slows down reads because queries must scan it in addition to the main index.

For read-heavy search tables you can tune or disable this behavior. Disabling fastupdate makes each insert do more work but keeps queries consistently fast.

ALTER INDEX idx_articles_search_doc
  SET (fastupdate = off);

-- Or cap the pending list size instead of disabling it:
ALTER INDEX idx_articles_search_doc
  SET (gin_pending_list_limit = 4096);

The Pre-12 Pattern: Trigger-Maintained Column

Generated columns arrived in PostgreSQL 12. On older versions, or when you need logic that isn't IMMUTABLE, you maintain the tsvector with a trigger.

The classic helper is tsvector_update_trigger, which fills a target column from named source columns. Note its limitation: it uses a single, fixed weight and a fixed config, so for per-field weighting you write a custom BEFORE trigger function instead.

ALTER TABLE articles ADD COLUMN search_doc tsvector;

CREATE TRIGGER trg_articles_search_doc
  BEFORE INSERT OR UPDATE ON articles
  FOR EACH ROW
  EXECUTE FUNCTION
    tsvector_update_trigger(search_doc, 'pg_catalog.english', title, body);

Backfilling Existing Rows

A trigger only fires on future inserts and updates. Existing rows keep a NULL search_doc until you backfill them.

For a stored generated column, PostgreSQL backfills automatically when you add the column. For the trigger pattern, run a one-time UPDATE. On huge tables, do it in batches by primary-key range so you don't lock the whole table or bloat one giant transaction.

UPDATE articles
SET search_doc =
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(body,  '')), 'B')
WHERE id BETWEEN 1 AND 100000;

Verifying the Index Is Actually Used

Always confirm the planner uses your GIN index instead of falling back to a sequential scan. Common reasons it won't: the query expression doesn't match the indexed expression, the table is tiny, or statistics are stale.

Run EXPLAIN (ANALYZE, BUFFERS) and look for a Bitmap Index Scan on your index name. If you see a Seq Scan with a Filter, the index isn't being used, fix the expression match or run ANALYZE.

EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM articles
WHERE search_doc @@ to_tsquery('english', 'gin & index');

Quick Check

You have a large, read-heavy articles table. You want full-text queries that match titles more strongly than body text, stay sub-millisecond, and never re-parse text at query time. Which design best meets all three goals?

Recap

You designed a high-performance full-text search column from end to end:

  • Precompute the document in a STORED generated tsvector column so text is parsed once, not per query.
  • Weight fields with setweight() (A for title, B for body) so ts_rank can score matches meaningfully.
  • Index the column with GIN, the read-optimized inverted index for @@ matching; prefer GiST only for very write-heavy churn.
  • Tune writes via fastupdate and gin_pending_list_limit when the pending list slows reads.
  • Maintain pre-12 tables with a trigger and backfill existing rows in batches.
  • Verify with EXPLAIN (ANALYZE, BUFFERS) that you get a Bitmap Index Scan, not a Seq Scan.

คำถามที่พบบ่อย

บทเรียน “การออกแบบคอลัมน์ tsvector และดัชนี GIN” ฟรีหรือไม่

ใช่ — ข้อความเต็มของ “การออกแบบคอลัมน์ tsvector และดัชนี GIN” ฟรีให้อ่านที่นี่บนเว็บ เพื่อปฏิบัติแบบโต้ตอบ (ตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7) และปลดล็อคส่วนที่เหลือของคอร์ส PostgreSQL Performance & Query Optimization ให้อัปเกรดเป็น CoddyKit PRO คอร์ส PostgreSQL Performance & Query Optimization มีบทเรียนทั้งหมด 4 บทเรียน

คุณจะเรียนรู้อะไรในบทเรียน “การออกแบบคอลัมน์ tsvector และดัชนี GIN”

คำนวณเอกสารสำหรับค้นหาไว้ล่วงหน้าและสร้างดัชนี เพื่อให้คำค้นเต็มข้อความใช้เวลาต่ำกว่ามิลลิวินาทีแม้มีข้อมูลมาก คุณปฏิบัติ PostgreSQL Performance & Query Optimization ด้วยโค้ดที่ใช้งานได้จริงที่คุณเรียกใช้โดยตรงในเบราว์เซอร์ และติวเตอร์ AI ตลอด 24/7 ตอบคำถามของคุณขณะที่คุณไปผ่านบทเรียน

คุณต้องมีประสบการณ์ก่อนที่จะเริ่มเรียน PostgreSQL Performance & Query Optimization หรือไม่

ไม่จำเป็นต้องมีประสบการณ์มาก่อน PostgreSQL Performance & Query Optimization บน CoddyKit ออกแบบมาสำหรับผู้เริ่มต้นไปจนถึงผู้เรียนขั้นสูง คุณสามารถเริ่มต้นที่นี่หรือเริ่มจากตัวแรกและเรียนด้วยความเร็วของคุณเอง นี่คือบทเรียนที่ 1 จากทั้งหมด 4 บทเรียน

บทเรียน “การออกแบบคอลัมน์ tsvector และดัชนี GIN” ใช้เวลานานแค่ไหน

บทเรียน CoddyKit ส่วนใหญ่ใช้เวลาประมาณ 5–10 นาที แต่ละบทเรียนจึงสั้นและเป็นแบบโต้ตอบ คุณสามารถก้าวหน้าอย่างต่อเนื่องและกลับมาเรียนต่อจากตรงที่เพิ่งหยุดบนเว็บและแอปได้เลย

ฉันเขียนและรันโค้ดในบทเรียน PostgreSQL Performance & Query Optimization นี้ได้ไหม

ได้ บทเรียน PostgreSQL Performance & Query Optimization ทุกบทมีตัวแก้ไขโค้ดในตัว คุณจึงเขียนและรันโค้ดจริงได้เลยในเบราว์เซอร์ และได้รับข้อเสนอแนะจาก AI ในทันที — ไม่ต้องติดตั้งในเครื่องของคุณ

บทเรียนทั้งหมดในหลักสูตรนี้

  1. การออกแบบคอลัมน์ tsvector และดัชนี GIN
  2. การจัดอันดับและปรับความเกี่ยวข้องด้วย ts_rank
  3. การจับคู่แบบคลุมเครือด้วยความคล้ายคลึงของ pg_trgm
  4. การรวมตัวกรองกับเพรดิเคตการค้นหา
← กลับไปที่ PostgreSQL Performance & Query Optimization