{"post":{"slug":"how-to-add-full-text-search-2026","title":"How to add full-text search in 2026: Postgres FTS, Typesense, and when to use each","excerpt":"Full-text search in 2026 runs the spectrum from Postgres built-in FTS to dedicated engines. This guide shows which to pick and how to implement each correctly.","category":"code","date":"April 14, 2026","author":"ByteLedger Team","content":"<span class=\"dropcap\">F</span>ull-text search has a reputation for being complicated, but in 2026 you have a clear decision tree: Postgres built-in FTS covers most apps, Typesense or Meilisearch covers search-first products, and Elasticsearch is overkill until you're at serious scale. Here is how to implement each correctly.\n\n## What changed in 2026\n\n- **Postgres 16/17 FTS** improved phrase search and `ts_rank` performance; it handles tens of millions of rows well with a GIN index.\n- **Typesense 27** supports multi-tenant, geosearch, and vector hybrid search (combining BM25 with embeddings) — a compelling single search layer for modern apps.\n- **Meilisearch 1.x** is production-stable with excellent developer experience and a cloud offering.\n- **pgvector + pg_trgm combo** is a popular pattern for apps that need both semantic (embedding) and lexical (keyword) search in one place.\n\n## Option comparison\n\n| Option | Typo tolerance | Facets | Vector search | Ops overhead | Best for |\n| --- | --- | --- | --- | --- | --- |\n| Postgres FTS | No | No | Via pgvector | None | Existing Postgres apps, basic search |\n| Typesense | Yes | Yes | Yes (hybrid) | Low | Search-first products |\n| Meilisearch | Yes | Yes | Yes | Low | Fast setup, great DX |\n| Elasticsearch | Yes | Yes | Yes | High | Massive scale, log analytics |\n| Algolia | Yes | Yes | Yes | None (SaaS) | Speed, global CDN, high cost |\n\n## Postgres FTS: the right setup\n\n**Step 1: add the `tsvector` column and update it with a trigger**\n\n```sql\nALTER TABLE articles ADD COLUMN search_vector tsvector;\n\n-- Trigger to update on insert or update\nCREATE OR REPLACE FUNCTION articles_search_vector_update() RETURNS trigger AS $$\nBEGIN\n  NEW.search_vector :=\n    setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') ||\n    setweight(to_tsvector('english', coalesce(NEW.body, '')), 'B');\n  RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE TRIGGER articles_search_vector_trigger\nBEFORE INSERT OR UPDATE ON articles\nFOR EACH ROW EXECUTE FUNCTION articles_search_vector_update();\n\n-- Backfill existing rows\nUPDATE articles SET search_vector =\n  setweight(to_tsvector('english', coalesce(title, '')), 'A') ||\n  setweight(to_tsvector('english', coalesce(body, '')), 'B');\n```\n\n**Step 2: GIN index**\n\n```sql\nCREATE INDEX CONCURRENTLY idx_articles_search_vector\nON articles USING GIN(search_vector);\n```\n\n**Step 3: query with ranking**\n\n```sql\nSELECT id, title,\n  ts_rank_cd(search_vector, query) AS rank\nFROM articles,\n  websearch_to_tsquery('english', $1) query\nWHERE search_vector @@ query\nORDER BY rank DESC\nLIMIT 20;\n```\n\nUse `websearch_to_tsquery` (Postgres 11+) — it parses natural-language input including `AND`, `OR`, and quoted phrases, which `to_tsquery` does not.\n\n## Typesense: quick setup\n\n```bash\ndocker run -p 8108:8108 \\\n  -v /tmp/typesense-data:/data \\\n  typesense/typesense:27 \\\n  --data-dir /data --api-key=xyz\n```\n\n```ts\nimport Typesense from 'typesense';\n\nconst client = new Typesense.Client({\n  nodes: [{ host: 'localhost', port: 8108, protocol: 'http' }],\n  apiKey: 'xyz',\n  connectionTimeoutSeconds: 2,\n});\n\n// Create collection (schema)\nawait client.collections().create({\n  name: 'articles',\n  fields: [\n    { name: 'id', type: 'string' },\n    { name: 'title', type: 'string' },\n    { name: 'body', type: 'string' },\n    { name: 'published_at', type: 'int64' },\n  ],\n  default_sorting_field: 'published_at',\n});\n\n// Index a document\nawait client.collections('articles').documents().upsert({\n  id: '1', title: 'How search works', body: 'Full text ...', published_at: Date.now(),\n});\n\n// Search\nconst results = await client.collections('articles').documents().search({\n  q: 'full text',\n  query_by: 'title,body',\n  sort_by: '_text_match:desc',\n});\n```\n\nFor production, sync Postgres → Typesense via a CDC (change data capture) pipeline or after-write hooks.\n\n## Keeping search in sync with Postgres\n\n| Pattern | How | Tradeoff |\n| --- | --- | --- |\n| Synchronous write | Write to Typesense in the same request handler | Simple; search is always fresh; adds ~10ms latency per write |\n| Background job | Enqueue a sync task after DB write | Slightly stale search; resilient to Typesense downtime |\n| CDC (Debezium) | Stream Postgres WAL changes to Typesense | Most reliable; most infrastructure |\n\nFor most apps, a background job (BullMQ, ARQ, Celery) is the right balance.\n\n## How to pick\n\n1. **Postgres FTS** — you're already on Postgres, search is a secondary feature, results don't need typo tolerance, volume is under ~5M documents.\n2. **Typesense or Meilisearch** — search is a core feature, users expect instant-as-you-type with typo tolerance, or you need faceted filtering.\n3. **Elasticsearch / OpenSearch** — you're at tens of millions of documents, need log search, or are already on the Elastic stack.\n4. **Algolia** — you want zero ops and can afford the pricing (~$1–5 per 1000 search operations).\n\n## Common mistakes\n\n**Calling `to_tsvector()` in the WHERE clause.** That recomputes the vector for every row on every query — slow at any scale. Store and index the vector.\n\n**Not using `websearch_to_tsquery`.** User input like \"react hooks\" breaks `to_tsquery`; `websearch_to_tsquery` handles it gracefully.\n\n**Ignoring language configuration.** `to_tsvector('english', ...)` applies stemming and stop words. Use the language that matches your content; multilingual apps need per-row language tracking.\n\n**Syncing search outside a transaction.** If the DB write succeeds but the search index update fails, your index drifts. Use a retry queue.\n\n**No result ranking.** Returning results in arbitrary order is a bad user experience. Always rank by `ts_rank_cd` or Typesense's `_text_match`.\n\n## What to skip\n\n- **Elasticsearch for a new project with standard search needs** — the ops overhead and JVM memory requirements rarely pay off under 10M documents.\n- **LIKE '%query%' for search** — no index, full table scan, no ranking. Replace it with FTS.\n- **Postgres trigram (`pg_trgm`) as a primary search strategy** — it's useful for fuzzy matching on short strings (usernames, codes) but not for paragraph search.\n\n## FAQ\n\n**Can Postgres FTS handle millions of rows?**\nYes. With a GIN index on a `tsvector` column, Postgres handles 10–50M rows with sub-100ms search queries on modest hardware.\n\n**How do I add multilingual support?**\nStore a `search_lang` column per row, use it in `to_tsvector(search_lang::regconfig, ...)`, and build separate indexes per language, or use the `simple` configuration as a universal fallback.\n\n**Does Typesense support vector/semantic search?**\nYes, as of Typesense 0.25+ with hybrid search (BM25 + embeddings). You embed the query and documents, store the embedding in a `float[]` field, and combine keyword and vector scores.\n\n**How do I test search quality?**\nBuild a small set of query/expected-result pairs and measure recall@k (how often the correct result appears in the top k). Run this in CI after any schema or ranking change.\n\n## Where to go next\n\nSee [How to set up Postgres locally in 2026](/blog/how-to-set-up-postgres-locally-2026), [How to write a database migration in 2026](/blog/how-to-write-a-database-migration-2026), and [How to add search to a site in 2026](/blog/how-to-add-search-to-a-site-2026).","hue":306,"metaTitle":"How to Add Full-Text Search 2026: Postgres FTS vs Typesense","metaDescription":"Add full-text search in 2026 with Postgres tsvector or Typesense. Covers GIN indexes, weighted ranking, typo tolerance, and when each option makes sense.","keywords":["how to add full-text search 2026","postgres full text search tutorial","tsvector tsquery postgres","typesense vs meilisearch 2026","full text search gin index","search ranking postgres","add search to postgres","full text search implementation","elasticsearch alternative 2026","postgres fts weighted ranking"],"tldr":["**Postgres FTS is enough for most apps** — `tsvector` + `tsquery` + a GIN index handles millions of rows with sub-100ms queries and zero extra infrastructure.","**Use Typesense or Meilisearch for search-first UX** — typo tolerance, faceting, and instant-as-you-type results that Postgres cannot match natively.","**Index at write time, not query time** — store a `search_vector tsvector` column updated by a trigger; do not call `to_tsvector()` on every search query.","**Weight fields differently** — title matches should rank higher than body; use `setweight()` and `ts_rank_cd` to get meaningful result ordering.","**Skip:** Elasticsearch for a new project under 10 million documents. The operational overhead and cost rarely justify it at that scale in 2026."],"readTime":"5 min read","wordCount":1062},"related":[{"slug":"how-to-add-search-to-a-site-2026","title":"How to add search to a site in 2026","excerpt":"Site search ranges from a client-side index for a blog to a full Elasticsearch cluster for an enterprise app. This guide maps the right tool to your actual scale and stack.","category":"code","date":"March 11, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":320},{"slug":"database-indexing-explained-2026","title":"Database indexing explained in 2026: what every developer must know","excerpt":"Bad indexes cause more production incidents than almost any other root cause. Here is the 2026 guide to choosing, building, and maintaining the right ones.","category":"code","date":"March 10, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":318},{"slug":"gin-indexes-explained-2026","title":"GIN Indexes: Indexing Values Inside a Column","excerpt":"A B-tree indexes a column's value. A GIN index indexes the things inside it — array elements, document keys, words in a text field — which is a different problem.","category":"code","date":"January 7, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":316}],"sameCategory":[{"slug":"bazel-build-explained-2026","title":"Bazel Explained: What Hermetic Builds Actually Cost","excerpt":"Bazel delivers reproducible, precisely cached builds and demands that you describe everything. Here is what you get, what it costs, and who should say no.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":145},{"slug":"graphql-federation-2026","title":"GraphQL Federation: One Graph, Many Teams","excerpt":"Federation composes independently-owned subgraphs into a single API. Here is what it solves, the operational cost it adds, and when a simpler gateway is enough.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":300},{"slug":"hermetic-builds-explained-2026","title":"Hermetic Builds Explained: Same Inputs, Same Output","excerpt":"A hermetic build depends only on declared inputs. Here is what that property gives you, the common leaks that break it, and how to move toward it incrementally.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":130},{"slug":"monorepo-tooling-compared-2026","title":"Monorepo Tooling Compared: What You Actually Need","excerpt":"Monorepo tools solve task orchestration and caching, not repository layout. Here is what each tier provides and how to tell when you have outgrown the simple option.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":150}],"newer":{"slug":"hash-tables-explained-2026","title":"Hash tables explained in 2026: internals, collisions, and real use cases","excerpt":"Hash tables power the O(1) lookups behind Python dicts, JavaScript objects, and Redis. Here is how they work, where they fail, and when to use them.","category":"code","date":"April 14, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":312},"older":{"slug":"how-to-appeal-a-medical-bill-2026","title":"How to appeal a medical bill in 2026: dispute errors and reduce what you owe","excerpt":"Medical billing errors are common and appeals succeed more often than patients expect. Here is a step-by-step process for disputing and reducing medical bills in 2026.","category":"finance","date":"April 14, 2026","author":"ByteLedger Team","readTime":"6 min read","hue":308}}