7. JSON and search
Sections
Reading settings
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
The lesson’s script is sql/07-json-search.sql, the exercises are in sql/07-exercises.sql, and check.sh compares their output with expected/07-json-search.txt and expected/07-exercises.txt.
Two kinds of data this time. The documents are the GitHub API’s job objects, as the CI snapshot exported them in jobs.json: lesson 2 flattened them into ci.jobs and ci.steps, this lesson keeps them whole. The text is this site: data/extract_pages.py took the prose of the DuckDB course and of this course, in English, French and Spanish, at commit a1df189, without code blocks or link targets, into data/pages.json: 48 pages.
| SQL Server | PostgreSQL |
|---|---|
JSON in nvarchar(max), ISJSON; the json type (preview in SQL Server 2025) |
jsonb, parsed and validated on input |
JSON_VALUE, JSON_QUERY |
->>, ->, #>>, jsonb_path_query |
OPENJSON … WITH (…) |
JSON_TABLE(… COLUMNS (…)), jsonb_to_recordset |
index on a computed column over JSON_VALUE |
GIN on the whole document, or a B-tree on an expression |
full-text catalog and index, CONTAINS, FREETEXT |
tsvector column with a GIN index, @@ |
CONTAINSTABLE rank |
ts_rank, ts_rank_cd |
LIKE '%…%' scans |
pg_trgm index |
json and jsonb
Section titled “json and jsonb”-- json keeps the text as it was written; jsonb stores a parsed value: keys sorted, duplicates dropped, the last one keptSELECT '{"name": "build", "labels": ["ubuntu-latest"], "name": "deploy"}'::json AS json, '{"name": "build", "labels": ["ubuntu-latest"], "name": "deploy"}'::jsonb AS jsonb; json | jsonb------------------------------------------------------------------+------------------------------------------------- {"name": "build", "labels": ["ubuntu-latest"], "name": "deploy"} | {"name": "deploy", "labels": ["ubuntu-latest"]}(1 row)json checks the text and stores it as written, duplicate keys included. jsonb stores a parsed tree: keys sorted by length then bytes, one value per key, the last one written. jsonb is the one to use, unless the exact text must come back.
Operators
Section titled “Operators”The documents, one row per job (lines 11-23):
-- The GitHub API's job documents, as they were exported: one jsonb value per jobCREATE TABLE ci.job_docs ( doc jsonb NOT NULL);INSERT INTO ci.job_docsSELECT j FROM jsonb_array_elements(pg_read_file('/course/data/jobs.json')::jsonb) AS j;
-- -> returns jsonb, ->> returns text, #>> follows a pathSELECT doc->>'name' AS name, doc->'labels' AS labels, doc->'labels'->>0 AS first_label, doc#>>'{steps,0,name}' AS first_step, jsonb_array_length(doc->'steps') AS stepsFROM ci.job_docsWHERE (doc->>'run_id')::bigint = 34852867099ORDER BY name;CREATE TABLEINSERT 0 315 name | labels | first_label | first_step | steps--------+-------------------+---------------+------------+------- build | ["ubuntu-latest"] | ubuntu-latest | Set up job | 6 deploy | ["ubuntu-latest"] | ubuntu-latest | Set up job | 3(2 rows)pg_read_filereads a file on the server; by default, only a superuser may call it.jsonb_array_elementsturns the array into rows.->returnsjsonb,->>returnstext,#>>follows a path given as a text array. Array positions start at 0 injsonb, where SQL arrays start at 1.- A comparison with a number needs a cast:
(doc->>'run_id')::bigint.
Containment and existence (lines 25-29):
-- Containment and existenceSELECT count(*) FILTER (WHERE doc @> '{"steps": [{"conclusion": "failure"}]}') AS with_a_failed_step, count(*) FILTER (WHERE doc->'labels' ? 'windows-latest') AS on_windows, count(*) FILTER (WHERE doc->'labels' ?| '{macos-latest, windows-latest}') AS on_macos_or_windowsFROM ci.job_docs; with_a_failed_step | on_windows | on_macos_or_windows--------------------+------------+--------------------- 22 | 35 | 69(1 row)@>asks whether the left document contains the right one. For an array, containment means “has an element that contains”: 22 jobs have at least one step whose conclusion isfailure.?asks whether a string is a key, or an element of an array;?|whether any of several is,?&whether all are.
SQL/JSON paths and JSON_TABLE
Section titled “SQL/JSON paths and JSON_TABLE”A SQL/JSON path walks inside a document, with filters (lines 31-36):
-- An SQL/JSON path: the failed steps of each jobSELECT doc->>'name' AS job, jsonb_path_query(doc, '$.steps[*] ? (@.conclusion == "failure").name') #>> '{}' AS failed_stepFROM ci.job_docsWHERE doc @? '$.steps[*] ? (@.conclusion == "failure")'ORDER BY doc->>'completed_at', 1, 2LIMIT 5; job | failed_step---------------------------------------+------------- rust-for-csharp-java (ubuntu-latest) | Formatting rust-for-csharp-java (macos-latest) | Formatting rust-for-csharp-java (windows-latest) | Formatting rust-for-csharp-java (ubuntu-latest) | Formatting rust-for-csharp-java (macos-latest) | Formatting(5 rows)$.steps[*] ? (@.conclusion == "failure").name takes every step, keeps those whose conclusion is failure, and returns their name. jsonb_path_query returns one row per match, @? asks whether there is at least one. #>> '{}' turns a jsonb string into text, without its quotes.
JSON_TABLE, new in PostgreSQL 17, is the standard form of SQL Server’s OPENJSON … WITH (lines 38-48):
-- JSON_TABLE turns a document into rows and typed columns, like OPENJSON ... WITH in T-SQLSELECT s.number, s.name, s.conclusion, s.completed_at - s.started_at AS durationFROM ci.job_docs, JSON_TABLE(doc, '$.steps[*]' COLUMNS ( number smallint PATH '$.number', name text PATH '$.name', conclusion text PATH '$.conclusion', started_at timestamptz PATH '$.started_at', completed_at timestamptz PATH '$.completed_at')) AS sWHERE (doc->>'id')::bigint = 104004920113ORDER BY s.number; number | name | conclusion | duration--------+--------------------------------------+------------+---------- 1 | Set up job | success | 00:00:04 2 | Checkout | success | 00:00:02 3 | Install, build, and upload site | success | 00:00:48 5 | Post Install, build, and upload site | success | 00:00:00 6 | Post Checkout | success | 00:00:01 7 | Complete job | success | 00:00:00(6 rows)Each column has a type and a path; the timestamps are read as timestamptz, and their difference is an interval. Step 4 of this job doesn’t exist in the document: GitHub numbered the steps with a gap.
Documents are values: operators return new ones (lines 50-54):
-- Changing a document: || merges, - removes a key, jsonb_set replaces a value at a pathSELECT (doc - 'steps' - 'labels' || '{"rerun": true}') AS summary, jsonb_set(doc, '{labels,0}', '"ubuntu-24.04"')->'labels' AS labelsFROM ci.job_docsWHERE (doc->>'id')::bigint = 104004920113; summary | labels---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+------------------ {"id": 104004920113, "name": "build", "rerun": true, "run_id": 34852867099, "status": "completed", "conclusion": "success", "created_at": "2026-09-14T14:01:10Z", "started_at": "2026-09-14T14:01:14Z", "runner_name": "GitHub Actions 1000000319", "completed_at": "2026-09-14T14:02:12Z"} | ["ubuntu-24.04"](1 row)- removes a key, || merges two objects, jsonb_set replaces the value at a path. An UPDATE that changes one key writes the whole document again, as a new row version (lesson 6): large documents that change often belong in columns.
GIN on documents
Section titled “GIN on documents”315 documents fit in a few pages. The script copies them 40 times, and uses the plan helper of lesson 5 (lines 56-69):
-- GIN indexes on documents, on 40 copies of the jobsINSERT INTO ci.job_docs SELECT doc FROM ci.job_docs, generate_series(2, 40);VACUUM ANALYZE ci.job_docs;SELECT count(*) AS documents, pg_size_pretty(pg_total_relation_size('ci.job_docs')) AS size FROM ci.job_docs;SELECT * FROM pg_temp.plan($$ SELECT count(*) FROM ci.job_docs WHERE doc @> '{"steps": [{"name": "Compile-fail doctests", "conclusion": "failure"}]}'$$);CREATE INDEX job_docs_ops ON ci.job_docs USING gin (doc);CREATE INDEX job_docs_path_ops ON ci.job_docs USING gin (doc jsonb_path_ops);SELECT indexrelid::regclass AS index, pg_size_pretty(pg_relation_size(indexrelid)) AS sizeFROM pg_index WHERE indrelid = 'ci.job_docs'::regclass ORDER BY 1;SELECT * FROM pg_temp.plan($$ SELECT count(*) FROM ci.job_docs WHERE doc @> '{"steps": [{"name": "Compile-fail doctests", "conclusion": "failure"}]}'$$);INSERT 0 12285VACUUM documents | size-----------+------- 12600 | 15 MB(1 row)
QUERY PLAN----------------------------------------------------------------------------------------------------------- Aggregate (actual rows=1.00 loops=1) Buffers: shared hit+read=1881 -> Seq Scan on job_docs (actual rows=40.00 loops=1) Filter: (doc @> '{"steps": [{"name": "Compile-fail doctests", "conclusion": "failure"}]}'::jsonb) Rows Removed by Filter: 12560 Buffers: shared hit+read=1881(6 rows)
CREATE INDEXCREATE INDEX index | size----------------------+--------- ci.job_docs_ops | 1688 kB ci.job_docs_path_ops | 1752 kB(2 rows)
QUERY PLAN--------------------------------------------------------------------------------------------------------------------- Aggregate (actual rows=1.00 loops=1) Buffers: shared hit+read=85 -> Bitmap Heap Scan on job_docs (actual rows=40.00 loops=1) Recheck Cond: (doc @> '{"steps": [{"name": "Compile-fail doctests", "conclusion": "failure"}]}'::jsonb) Rows Removed by Index Recheck: 240 Heap Blocks: exact=80 Buffers: shared hit+read=85 -> Bitmap Index Scan on job_docs_path_ops (actual rows=280.00 loops=1) Index Cond: (doc @> '{"steps": [{"name": "Compile-fail doctests", "conclusion": "failure"}]}'::jsonb) Index Searches: 1 Buffers: shared hit+read=5(11 rows)A GIN index on jsonb comes in two operator classes:
jsonb_ops, the default, indexes every key and every value separately. It serves@>,?,?|,?&and path matches.jsonb_path_opsindexes a hash of each value with the path of keys that leads to it. It only serves@>and path matches, and is usually smaller. Here it came out slightly larger, 1,752 kB against 1,688 kB, and the planner chose it.
The index returned 280 documents, and the recheck on the table kept 40: the 7 jobs with a step named Compile-fail doctests and a failed step, 40 times, of which only one job had that step fail. The index knows that a document has both paths, not that they belong to the same array element. 85 pages instead of 1,881.
When queries always read the same key, a B-tree on the expression, such as ((doc->>'run_id')::bigint), is smaller and serves ranges and sorts, which GIN can’t.
Full-text search
Section titled “Full-text search”Searching text with LIKE finds strings, not words: index doesn’t match indexes, and every row is read. Full-text search turns a text into a tsvector, a sorted list of normalized words, lexemes, with their positions. A text search configuration decides how: which words to skip, and how to reduce a word to its stem (lines 71-92):
-- Full-text search: the prose of two courses of this site, in English, French and Spanish (data/extract_pages.py)CREATE SCHEMA site;CREATE TABLE site.pages ( course text NOT NULL, locale text NOT NULL, slug text NOT NULL, title text NOT NULL, body text NOT NULL, config regconfig NOT NULL, search tsvector GENERATED ALWAYS AS ( setweight(to_tsvector(config, title), 'A') || setweight(to_tsvector(config, body), 'B')) STORED, PRIMARY KEY (course, locale, slug));
-- A tsvector holds normalized words, lexemes, with their positions; a text search configuration decides howSELECT to_tsvector('english', 'Indexes and plans: the planner chose a Bitmap Index Scan') AS english, to_tsvector('french', 'Index et plans : le planificateur a choisi un parcours d''index') AS french, to_tsvector('simple', 'Indexes and plans') AS simple;
-- websearch_to_tsquery reads a search box's syntax: words, "phrases", or, -excludedSELECT websearch_to_tsquery('english', 'connection pool -aurora') AS query1, websearch_to_tsquery('english', '"index scan" or brin') AS query2;CREATE SCHEMACREATE TABLE english | french | simple-----------------------------------------------------------------+--------------------------------------------------------------+------------------------------- 'bitmap':8 'chose':6 'index':1,9 'plan':3 'planner':5 'scan':10 | 'a':6 'chois':7 'index':1,11 'parcour':9 'plan':3 'planif':5 | 'and':2 'indexes':1 'plans':3(1 row)
query1 | query2--------------------------------+----------------------------- 'connect' & 'pool' & !'aurora' | 'index' <-> 'scan' | 'brin'(1 row)englishremovesandandthe, and reducesIndexestoindexandchosestayschose: a stemmer cuts suffixes, it doesn’t know irregular verbs.frenchreducesplanificateurtoplanif, and keepsa, which isn’t in its stop words.simpleonly lowercases.websearch_to_tsqueryreads what people type in a search box: words are joined by&,-excludes,oris|, and a quoted phrase becomes<->, “followed by”.
site.pages stores each page with its configuration, a regconfig, and a stored generated tsvector that weighs the title (A) above the body (B). The configuration is a column of its own, set by the INSERT: a generated column can’t refer to another generated column.
INSERT INTO site.pages (course, locale, slug, title, body, config)SELECT p.course, p.locale, p.slug, p.title, p.body, CASE p.locale WHEN 'fr' THEN 'french' WHEN 'es' THEN 'spanish' ELSE 'english' END::regconfigFROM jsonb_to_recordset(pg_read_file('/course/data/pages.json')::jsonb->'pages') AS p( course text, locale text, slug text, title text, body text);CREATE INDEX pages_search ON site.pages USING gin (search);Ranking
Section titled “Ranking”-- The pages that match, best first: title matches weigh more than body matchesSELECT course, slug, round(ts_rank(search, query)::numeric, 3) AS rankFROM site.pages, websearch_to_tsquery('english', 'connection pool') AS queryWHERE locale = 'en' AND search @@ queryORDER BY rank DESC, course, slugLIMIT 5;INSERT 0 48CREATE INDEX course | slug | rank-------------------+----------------+------- postgresql-aurora | 04-csharp-java | 1.000 postgresql-aurora | 03-queries | 0.625 postgresql-aurora | index | 0.381(3 rows)@@ matches a tsvector with a tsquery, through the GIN index. ts_rank scores how often the words appear, and with which weight: lesson 4 is the page about connection pools. The ranking functions don’t know anything else about a page; a search engine that needs more, such as synonyms, typos in several words or relevance learned from clicks, is a different tool.
ts_headline shows the matching words in their context (lines 108-112):
-- ts_headline shows the matching words in contextSELECT slug, regexp_replace(ts_headline('english', body, query, 'MaxFragments=1, MaxWords=12, MinWords=6, StartSel=[, StopSel=]'), '\s+', ' ', 'g') AS excerptFROM site.pages, websearch_to_tsquery('english', '"prepared statements"') AS queryWHERE locale = 'en' AND course = 'postgresql-aurora' AND search @@ queryORDER BY slug; slug | excerpt----------------+--------------------------------------------------------------------------------------------------------- 04-csharp-java | beat hundreds of idle ones. ## [Prepared] [statements] A [prepared] [statement] is parsed index | JDBC and HikariCP, including pools, [prepared] [statements] and `COPY`; - read a query journal | hours throws `ArgumentException`. - `pg_[prepared]_[statements]` counted the counting query itself once(3 rows)The phrase "prepared statements" matched pg_prepared_statements in the journal: the parser splits that identifier at its underscores. regexp_replace only joins the lines of the excerpt, for the output.
Accents
Section titled “Accents”-- The same search in French finds nothing for a word written without its accentSELECT count(*) FILTER (WHERE search @@ websearch_to_tsquery('french', 'modélisation')) AS with_accent, count(*) FILTER (WHERE search @@ websearch_to_tsquery('french', 'modelisation')) AS without_accentFROM site.pages WHERE locale = 'fr';
-- unaccent in a configuration of our own removes accents before stemmingCREATE EXTENSION unaccent;CREATE TEXT SEARCH CONFIGURATION french_unaccent (COPY = french);ALTER TEXT SEARCH CONFIGURATION french_unaccent ALTER MAPPING FOR hword, hword_part, word WITH unaccent, french_stem;SELECT to_tsvector('french', 'modélisation des données') AS french, to_tsvector('french_unaccent', 'modélisation des données') AS french_unaccent;SELECT count(*) AS without_accentFROM site.pagesWHERE locale = 'fr' AND to_tsvector('french_unaccent', title || ' ' || body) @@ websearch_to_tsquery('french_unaccent', 'modelisation'); with_accent | without_accent-------------+---------------- 3 | 0(1 row)
CREATE EXTENSIONCREATE TEXT SEARCH CONFIGURATIONALTER TEXT SEARCH CONFIGURATION french | french_unaccent---------------------+----------------------- 'don':3 'modélis':1 | 'donne':3 'modelis':1(1 row)
without_accent---------------- 3(1 row)The french configuration keeps accents, so modelisation, typed without its accent, finds none of the three French pages that modélisation finds. The unaccent extension is a dictionary that removes accents; a configuration of our own runs it before the French stemmer, on documents and queries alike. The last query computes the tsvector on the fly, reading every French page: a real table would store it, or index the expression.
pg_trgm
Section titled “pg_trgm”pg_trgm cuts a string into trigrams, sequences of three characters, and compares strings by the trigrams they share (lines 131-137):
-- pg_trgm: similarity between strings, from their three-letter sequencesCREATE EXTENSION pg_trgm;SELECT show_trgm('Npgsql') AS trigrams;SELECT DISTINCT package, round(similarity(package, 'mongo driver')::numeric, 2) AS similarityFROM ga.package_refsWHERE package % 'mongo driver'ORDER BY similarity DESC, package;CREATE EXTENSION trigrams------------------------------------- {" n"," np",gsq,npg,pgs,"ql ",sql}(1 row)
package | similarity-----------------------+------------ MongoDB.Driver | 0.75 MongoDB.Driver.GridFS | 0.52(2 rows)Each word is padded with two spaces before and one after, and lowercased. % is true when the similarity, shared trigrams over all distinct trigrams, is above pg_trgm.similarity_threshold, 0.3 by default: mongo driver finds the package without its dot or capitals.
A trigram GIN index also serves LIKE and ILIKE with a wildcard at the start, which a B-tree can’t (lines 139-147):
-- A trigram index serves LIKE and ILIKE with a leading wildcard, which a B-tree can't: the steps of the 12,600 job documentsCREATE TABLE ci.doc_steps ASSELECT s.name, s.conclusionFROM ci.job_docs, JSON_TABLE(doc, '$.steps[*]' COLUMNS (name text PATH '$.name', conclusion text PATH '$.conclusion')) AS s;CREATE INDEX doc_steps_name_trgm ON ci.doc_steps USING gin (name gin_trgm_ops);VACUUM ANALYZE ci.doc_steps;SELECT * FROM pg_temp.plan($$ SELECT count(*) FROM ci.doc_steps WHERE name ILIKE '%doctest%'$$);SELECT 88160CREATE INDEXVACUUM QUERY PLAN------------------------------------------------------------------------------------ Aggregate (actual rows=1.00 loops=1) Buffers: shared hit+read=500 -> Bitmap Heap Scan on doc_steps (actual rows=1840.00 loops=1) Recheck Cond: (name ~~* '%doctest%'::text) Heap Blocks: exact=487 Buffers: shared hit+read=500 -> Bitmap Index Scan on doc_steps_name_trgm (actual rows=1840.00 loops=1) Index Cond: (name ~~* '%doctest%'::text) Index Searches: 1 Buffers: shared hit+read=13(10 rows)The index finds the rows whose names contain the trigrams of doctest, and the recheck confirms the match: 500 pages for 88,160 step names. In SQL Server, LIKE '%doctest%' reads every row of the index or table.
On Aurora
Section titled “On Aurora”To verify: nothing in this section ran on AWS. jsonb, JSON_TABLE and full-text search are part of PostgreSQL’s core, and I found no Aurora page that changes them. The extensions of this lesson are in the Aurora PostgreSQL 18 extension table at the same versions as in the image: pg_trgm 1.6 and unaccent 1.1. The table also has fuzzystrmatch, and pg_bigm, a bigram index meant for languages written without spaces between words.
Two parts of the lesson don’t carry over as they are:
- Loading files.
pg_read_filereads the server’s file system, and on Aurora “you can’t access the host OS, and you can’t connect using the PostgreSQLsuperuseraccount” (therds_superuserrole). The documents and pages would come from the client, withCOPY … FROM STDINas in lesson 4. - Migrating SQL Server’s full-text indexes. AWS’s migration playbook says that it “requires a full rewrite of the code that addresses creating, managing, and querying of full-text searches”, and that PostgreSQL’s engine, “significantly less comprehensive than SQL Server”, is “sufficiently powerful for most common, basic full-text requirements”. Its JSON chapter covers the functions. Both chapters are written for SQL Server 2019.
Key takeaways
Section titled “Key takeaways”jsonb, notjson;->returnsjsonb,->>text, and comparisons need a cast.@>and SQL/JSON paths query inside documents;JSON_TABLEturns them into typed rows.- GIN serves containment on whole documents;
jsonb_path_opsonly serves@>and paths; a B-tree on an expression serves one key. - Full-text search matches lexemes, not strings: the configuration chooses stop words and stemming, per language.
unaccentin a custom configuration makes accents optional.pg_trgmfinds typos and servesLIKE '%…%'with an index.
Exercises
Section titled “Exercises”The solutions are in sql/07-exercises.sql.
- From the job documents alone, count the failed steps by name, with a single
JSON_TABLEthat only returns failed steps. Then check that the steps in the documents are exactly those ofci.steps.
Solution
-- Exercise 1: the steps that failed, straight from the job documents, checked against the flattened ci.steps tableCREATE TABLE ci.job_docs ASSELECT j AS doc FROM jsonb_array_elements(pg_read_file('/course/data/jobs.json')::jsonb) AS j;
SELECT s.name AS step, count(*) AS failuresFROM ci.job_docs, JSON_TABLE(doc, '$.steps[*] ? (@.conclusion == "failure")' COLUMNS (name text PATH '$.name')) AS sGROUP BY s.nameORDER BY failures DESC, step;
SELECT count(*) AS differences FROM ( (SELECT (doc->>'id')::bigint, s.number, s.name FROM ci.job_docs, JSON_TABLE(doc, '$.steps[*]' COLUMNS (number smallint PATH '$.number', name text PATH '$.name')) AS s EXCEPT SELECT job_id, number, name FROM ci.steps) UNION ALL (SELECT job_id, number, name FROM ci.steps EXCEPT SELECT (doc->>'id')::bigint, s.number, s.name FROM ci.job_docs, JSON_TABLE(doc, '$.steps[*]' COLUMNS (number smallint PATH '$.number', name text PATH '$.name')) AS s)) AS d;SELECT 315 step | failures-----------------------------------------+---------- Formatting | 6 Run actions/setup-dotnet@v6 | 3 Run exit 3 | 3 Run ./.github/actions/hello-container | 2 Compile-fail doctests | 1 Deploy to GitHub Pages | 1 Fail | 1 Run date -u +%T | 1 Run ./.github/actions/dotnet-restore | 1 Run ./.github/actions/exercise-commonjs | 1 Run ./.github/actions/exercise-no-shell | 1 Tests (fail on demand) | 1(12 rows)
differences------------- 0(1 row)The row path of JSON_TABLE can filter: $.steps[*] ? (@.conclusion == "failure"). The 22 failed steps are those that lesson 2’s model gives. EXCEPT in both directions finds no difference between the documents and the table: the flattening lost no step, and added none.
- Search the Spanish pages for the phrase
"planes de ejecución", ranked. Why wouldn’t an English configuration work, even on these pages?
Solution
-- Exercise 2: the Spanish pages about execution plansCREATE TABLE site_pages ASSELECT p.* FROM jsonb_to_recordset(pg_read_file('/course/data/pages.json')::jsonb->'pages') AS p( course text, locale text, slug text, title text, body text);
SELECT course, slug, round(ts_rank(to_tsvector('spanish', title || ' ' || body), query)::numeric, 3) AS rankFROM site_pages, websearch_to_tsquery('spanish', '"planes de ejecución"') AS queryWHERE locale = 'es' AND to_tsvector('spanish', title || ' ' || body) @@ queryORDER BY rank DESC, course, slug;
SELECT websearch_to_tsquery('spanish', '"planes de ejecución"') AS spanish, websearch_to_tsquery('english', '"planes de ejecución"') AS english;SELECT 48 course | slug | rank-------------------+----------------+------- postgresql-aurora | index | 0.197 duckdb | index | 0.167 duckdb | 07-performance | 0.099(3 rows)
spanish | english--------------------+---------------------------------- 'plan' <2> 'ejecu' | 'plane' <-> 'de' <-> 'ejecución'(1 row)The Spanish stemmer reduces planes to plan and ejecución to ejecu, and drops de, a stop word, while remembering its place: <2> means “two positions later”. The English configuration doesn’t know that de is a stop word nor how Spanish plurals end: it looks for plane, de and ejecución in sequence, lexemes that the Spanish tsvectors don’t contain. The configuration of the query must be the one of the documents.
- A search box receives
dependncy injection, with a typo. Find the packages it means, first withsimilarity, then withword_similarity. Why do they differ?
Solution
-- Exercise 3: a search box receives "dependncy injection", with a typoCREATE EXTENSION pg_trgm;CREATE TABLE packages AS SELECT DISTINCT package FROM ga.package_refs;SELECT package, round(similarity(package, 'dependncy injection')::numeric, 2) AS similarityFROM packagesWHERE package % 'dependncy injection'ORDER BY similarity DESC, package;
SELECT package, round(word_similarity('dependncy injection', package)::numeric, 2) AS word_similarityFROM packagesWHERE 'dependncy injection' <% packageORDER BY word_similarity DESC, package;CREATE EXTENSIONSELECT 136 package | similarity------------------------------------------+------------ Microsoft.Extensions.DependencyInjection | 0.33(1 row)
package | word_similarity-------------------------------------------------------+----------------- Microsoft.Extensions.DependencyInjection | 0.60 Microsoft.Extensions.DependencyInjection.Abstractions | 0.60(2 rows)similarity compares the whole strings: the longer …DependencyInjection.Abstractions has many trigrams that the search doesn’t, and falls under 0.3. word_similarity measures how well the search matches the most similar part of the package name, and finds both at 0.60. <% is its operator, with its own threshold, 0.6 by default.
Sources
Section titled “Sources”- PostgreSQL 18 documentation: JSON types, JSON functions and operators, full-text search, text search types, controlling text search, configuration,
unaccent,pg_trgm, generic file access functions, generated columns - SQL Server:
OPENJSON, thejsondata type,CREATE JSON INDEX, full-text search, full-text catalogs,CONTAINS,FREETEXT,EDIT_DISTANCE - AWS: extension versions, the
rds_superuserrole, migration playbook: full-text search, JSON, all read on 2026-09-16