Vocab Bloom Hub
此页面仅提供英文版本。

Read performance on the full dictionary

How the API behaves on the real data volume — ~298k en_entries, ~327k en_words, ~161k en_meanings, ~807k meaning translations and ~576k short translations (five languages), ~509k synonym and ~183k antonym links — what was done about it, and how to measure it again.

Targets and where things stand

Read (public API)Target (p95)Postgres nowNote
Headword / id lookup (/words/{word}, /id/{id})20 ms≤ 4 msone query per relation, rows assembled by hand
Filtered list page (/words?…)20 ms≤ 5 msindex walk in (word, id) order, keyset cursor
Random entry (/random?…)20 ms≤ 7 msprimary-key pivot, no ORDER BY random()
Search, exact / prefix tiers (/search)20 ms≤ 8 msbyte-order index on the headword
Search, substring / suffix tiers20 ms≤ 8 mstrigram GIN index
Search, typo (fuzzy tier)20 ms≤ 10 mspg_trgm similarity when nothing else matches
Search, 1–2 character term (short-term flow)20 ms≤ 6 msexact and prefix tiers only, index lookups
List page with meanings joined (with_meanings)50 ms≤ 13 ms8 small queries instead of one exploding join
Batch lookup, 5 spellings (/words/batch)20 ms≤ 5 msthe same 9 statements as one headword
Batch lookup, 50 spellings (the cap)100 ms≤ 25 ms9 statements, ~80 entries, no entity hydration

注意

Numbers are p95 of the benchmark on a laptop against a local Postgres 18 with the published dataset; treat them as an order of magnitude, not a promise.

The full reads cost the same 9 statements whatever they return — the entries, then one IN (...) per relation for the whole set — so a batch's time grows with the entries it returns, not with the spellings: 50 everyday headwords are ~80 entries with ~240 meanings (a 285 KB answer) in ~22 ms. They used to take ~275 ms: the same statements, but find() with relations then spent ~220 ms turning the rows into entity instances. Now WordRowsService (src/modules/EnModule/word-rows.service.ts) runs the statements itself — in two concurrent waves after the entries (the collections of the entries, then those of the meanings), so a read costs three round-trips whatever the distance to the database — converts the raw values through the driver (the conversion hydration applies: booleans, enums, arrays, JSON, dates) and groups the collections by foreign key in one pass; a unit test pins its answer to find()'s field for field. The admin entry read, the detailed search and the list with joins go through it too.

警告

SQLite is a development and test database only. On the full dictionary its search tiers take 0.1 – 1 s per request and the admin statistics 0.25 s (tables below): LIKE cannot use its indexes, there is no pg_trgm (no fuzzy tier either) and the planner has no bitmap or parallel scans. The reads that became index lookups after this work (headword / id lookups ~1 ms, random ~8 ms, list pages 10 – 30 ms) are fine on SQLite too, so a full dev.sqlite stays usable for hacking on the API — but nothing else. NODE_ENV=production with a SQLite DATABASE_URL is a startup error, as before.

What was slow and what changed

The benchmark with --explain found seven problems; each is fixed by a change that the query-plan guard keeps in place.

  1. Loading an entry multiplied its rows. find() with nested relations joins everything in one statement, so a word with 4 forms × 4 meanings × 4 translations × 8 synonyms comes back as thousands of rows to deduplicate: 45 ms on Postgres and 2.2 s on SQLite for GET /api/v1/words/run. The reads that load the meaning tree now use relationLoadStrategy: 'query' — one query per relation (~20 small statements, each an index lookup) — 5 – 8 ms on Postgres, ~1 ms on SQLite. Every relation load uses it, even the light word + forms of a list page: joins would be ~1 ms cheaper on Postgres there, but SQLite materializes the whole forms table for that join (0.6 s per page), and a bounded handful of index lookups has no worst case to discover later.

  2. Prefix search could not use an index. entry.word LIKE 'xylo%' under the en_US collation is a sequential scan of en_entries; a rare term ran every tier at full cost — 490 ms. The prefix tiers (starts-with, phrase start) and the admin prefix filter now compare through LOWER(word) COLLATE "C" (the foldedWord() helper; case-folded so the capitalised grammar patterns stay findable), served by IDX_EN_ENTRY_WORD_LOWER_C: 35 ms, the rest being the substring tiers, which the trigram index (below) brought to 7 ms.

  3. The list had no index for its filters. ?category=IT scanned and sorted all of en_words: 244 ms. AddWordFilterIndexes adds a btree per filter column (word_level, language_register, area_variant, form_of_word), a GIN over the categories array (the filter uses the overlap operator &&, the only indexable form) and IDX_EN_WORD_LOWER_C on (LOWER(word) COLLATE "C", id), the order the list pages in. The list no longer joins en_entries: the same headword is on en_words, and the planner either walks IDX_EN_WORD_LOWER_C (unselective filters) or bitmap-ANDs the filter indexes and top-N sorts (selective ones). 2 – 4 ms either way.

  4. The keyset cursor started the index walk from the beginning. word > :w OR (word = :w AND id > :id) is a filter, not a range start: a page at "m" read half the index (23 ms). The row comparison (word, id) > (:w, :id) is one index range condition: 0.1 ms (SQLite uses its word index for it too).

  5. The random draw scanned for its bounds. MIN(id)/MAX(id) under the filters was a sequential scan (38 ms). The pivot is now drawn in the id range of the whole table (two primary-key lookups) and the first matching row at or after it is taken, wrapping around to the last one before it: 4 – 7 ms whatever the filter.

  6. Substring, suffix and word-boundary search tiers (%term, %term%, % term %) were sequential scans of en_entries — a btree cannot serve them. AddEntryWordTrigramIndex enables pg_trgm and adds IDX_EN_ENTRY_WORD_TRGM, a GIN over the trigrams of the headword: the same LIKEs now read the index (a rare term 35 → 7 ms, a phrase 38 → 5.5 ms), and a fuzzy tier answers typos through the similarity operator when no other tier matches (recieverelieve, retrieve, … in 8 ms, meta.fuzzy, similarity per item — see api.md). Very short terms (ab) have no full trigram and get their own flow (below).

  7. One- and two-character terms ran every tier. %a% matches ~175k of 298k headwords, so the suffix, substring and phrase tiers answered an arbitrary slice of half the dictionary (on Postgres a scan cut short by the LIMIT, on SQLite ~0.7 s), and a term with no full trigram has nothing to look up in the GIN. A term shorter than SEARCH_MIN_SUBSTRING_LENGTH (3) stops after the exact and prefix tiers — both index lookups — and answers meta.short_term: true; a blank term answers nothing without a query. a and ab: 5 – 6 ms, every statement an index lookup (the guard covers them).

Not changed, by design:

  • Admin listings count their total with COUNT(DISTINCT …) over a join and page by offset: 20 – 45 ms, admin-only, not part of the public contract.
  • Statistics are counts over whole tables by nature (50 ms, cached by the UI).
  • Last-Modified (ORDER BY updateAt DESC LIMIT 1 on five tables) is a sort without an index, run at most once a minute behind a cache.

The benchmark

yarn workspace server bench                     # DATABASE_URL from the root .env
yarn workspace server bench --iterations 100    # more samples (default 30 after 3 warm-up calls)
yarn workspace server bench --explain           # Postgres: EXPLAIN every statement, report Seq Scans
yarn workspace server bench --json out.json     # raw numbers
DATABASE_URL=sqlite:../../dev.sqlite yarn workspace server bench   # the same on SQLite (from apps/server)

src/bench/run.ts boots the real application on an ephemeral port and drives the scenarios of src/bench/scenarios.ts through HTTP — every search tier, the headword / id lookups, the batch lookup (5 spellings and the 50-spelling cap), the list at several selectivities and cursor depths, the random draw, the meta endpoint and the admin listings. It reports p50 / p95 / max latency and, through a TypeORM logger attached to the data source (QueryRecorder), how many statements one request costs — the N+1 audit in one column. --explain runs EXPLAIN (FORMAT JSON) on each recorded statement with its parameters bound and prints the sequential scans over the large tables.

注意

The scenarios need the loaded dictionary (they look up the verb run). Load it with the import page or docs/offline-import.md.

The query-plan guard

yarn workspace server test:postgres      # needs a postgres:// DATABASE_URL with the migrations applied

test/query-plans.pg-spec.ts runs the public scenarios with SET enable_seqscan = off and explains every statement they issue: with sequential scans discouraged the planner picks an index path whenever one exists, so a Seq Scan on en_entries, en_words, en_meanings or the translation and link tables means no index can serve that query — on the full dictionary and on the empty database of CI alike. The postgres-only tests job of check-pull-request runs it against a fresh Postgres service after migration:run, so a query change that loses its index fails the pull request. The only statements skipped on purpose are the Last-Modified lookups (see above); the same job runs the trigram search suite (test/fuzzy-search.pg-spec.ts).

Indexes on the large tables

Beyond the primary keys and the foreign-key indexes TypeORM creates for relations:

TableIndexServes
en_entriesIDX_EN_ENTRY_WORD_LOWER_C on (LOWER(word) COLLATE "C")prefix search tiers, admin prefix filter
en_entriesIDX_EN_ENTRY_WORD_TRGM GIN on (word gin_trgm_ops)substring / suffix / phrase search tiers, the fuzzy tier (%) — needs pg_trgm
en_wordsIDX_EN_WORD_LOWER_C on (LOWER(word) COLLATE "C", id)list order and cursor
en_wordsIDX_EN_WORD_LEVEL, IDX_EN_LANGUAGE_REGISTER, IDX_EN_AREA_VARIANT, IDX_EN_FORM_OF_WORDlist and random filters (bitmap-ANDed)
en_wordsIDX_EN_CATEGORIES GIN on (categories)?category= (categories && ARRAY[…])
en_wordsIDX_EN_WORD, IDX_EN_WORD_LOOKUP, IDX_EN_PART_OF_SPEECH, IDX_EN_BASE_FORM, IDX_EN_BASE_PHRASAL, IDX_EN_PHRASAL_SEARCHheadword lookups, part of speech, forms and phrasal links
en_meaningsIDX_EN_MEANING_WORD, IDX_EN_MEANING_WORD_SORTmeanings of an entry
translations, links…_MEANING, …_WORD, …_LANGUAGE, junction primary keystranslations and synonym / antonym links of a meaning

Indexes a decorator cannot express (COLLATE "C", GIN) are declared on the entity with MANUALLY_MANAGED_INDEX (synchronize: false) and created by their migration (AddCaseFoldedWordIndexes, AddWordFilterIndexes, AddEntryWordTrigramIndex); migration:generate leaves them alone. An expression index (LOWER(word) COLLATE "C") gets planner statistics only from ANALYZE, which its migration runs right after creating it — without them a one-row lookup on the expression is estimated at 0.5 % of the table and planned with parallel workers, ~5 ms of start-up instead of 0.1 ms; autovacuum keeps the statistics fresh afterwards. SQLite gets the plain btrees through synchronize and needs nothing for the rest.

Numbers

p50 before → after the changes above (the Postgres "after" includes the trigram index and the short-term flow; the fuzzy and one-letter scenarios did not exist before), then p95 and max after; "queries" is the number of SQL statements one request issues. Postgres 18, 30 iterations; SQLite (better-sqlite3 on the same dev.sqlite, no trigram index), 10 – 20 iterations. Same laptop, same dataset.

Postgres

ScenarioQueries before → afterp50 before → afterp95 aftermax after
search exact "run" (+ phrasal, prefix tiers)2 → 83.8 → 6.57.38.9
search one letter "a" (short-term flow)— → 9— → 5.66.06.0
search broad prefix "ab" (short-term flow)4 → 96.9 → 5.36.06.8
search rare "xylo"8 → 12491 → 6.98.28.8
search phrase "put up"6 → 1231.7 → 5.56.98.0
search typo "recieve" (fuzzy tier)— → 13— → 8.59.611.7
search no match "qzxvj" (fuzzy tier, empty)— → 6— → 3.33.74.0
search detailed "run" + meanings + translations2 → 1716.0 → 13.914.714.7
word "run" (noun + verb, full)2 → 2245.3 → 6.88.38.4
word "ran" (form → base entry)2 → 2242.7 → 5.56.56.9
word by id (run, verb)2 → 2142.0 → 5.05.87.5
word "run" translations2 → 2245.0 → 5.76.67.3
list first page (no filter)2 → 82.0 → 2.93.43.9
list word_level=B12 → 83.3 → 2.83.14.2
list noun + C22 → 82.9 → 3.74.54.7
list category=IT (rare)2 → 8244 → 4.14.85.1
list language_register=slang2 → 516.7 → 2.13.03.3
list form_of_word=past_simple2 → 53.1 → 2.02.42.5
list cursor at "m" (deep page)2 → 89.1 → 3.64.14.5
list cursor at "m" + word_level=C12 → 88.1 → 3.13.44.0
list 50 + meanings + translations2 → 1712.1 → 13.720.223.9
random (no filter)4 → 1938.7 → 5.06.57.2
random A1 noun4 → 1920.9 → 5.27.07.8
random category=medical4 → 1727.7 → 4.55.26.5
meta1 → 10.5 → 0.40.50.7
admin words word_level=B1 (page 1 + total)2 → 225.9 → 19.521.927.1
admin words search=un (prefix)2 → 236.1 → 37.738.839.4
admin meanings part_of_speech=verb3 → 345.1 → 44.646.949.3
admin word by id2 → 2142.7 → 6.06.67.6
admin statistics15 → 1552.3 → 51.657.858.9

SQLite

ScenarioQueries before → afterp50 before → afterp95 aftermax after
search exact "run" (+ phrasal, prefix tiers)2 → 81185 → 642652655
search broad prefix "ab"8 → 11683 → 119122129
search rare "xylo"8 → 12869 → 297299305
search phrase "put up"6 → 121240 → 945950955
search detailed "run" + meanings + translations2 → 161720 → 655669673
word "run" (noun + verb, full)2 → 212191 → 1.21.41.4
word "ran" (form → base entry)2 → 211916 → 1.01.11.2
word by id (run, verb)2 → 202487 → 1.01.11.1
word "run" translations2 → 212211 → 1.11.31.4
list first page (no filter)2 → 8657 → 27.327.628.6
list word_level=B12 → 8634 → 10.110.210.6
list noun + C22 → 8610 → 14.615.015.9
list category=IT (rare)2 → 8631 → 28.629.130.4
list language_register=slang2 → 5625 → 0.80.81.0
list form_of_word=past_simple2 → 5600 → 6.77.07.1
list cursor at "m" (deep page)2 → 8658 → 29.430.431.1
list cursor at "m" + word_level=C12 → 8642 → 18.319.019.2
list 50 + meanings + translations2 → 161178 → 34.936.139.6
random (no filter)4 → 192538 → 7.47.67.8
random A1 noun4 → 192527 → 8.08.58.6
random category=medical4 → 192520 → 7.68.28.5
meta1 → 10.3 → 0.20.30.3
admin words word_level=B1 (page 1 + total)2 → 279.2 → 26.728.028.8
admin words search=un (prefix)2 → 2123 → 102103108
admin meanings part_of_speech=verb3 → 355.9 → 56.858.263.1
admin word by id2 → 202460 → 1.11.31.3
admin statistics15 → 15253 → 264266266