The short answer.
search_legislation finds texts by their words. Until 9 November PostgreSQL collected and scored every candidate text on every search. Now the image build writes, for each word, the list of texts that hold it, and the server ranks from those lists. On one CPU core, six searches at once finish within 54 to 56 ms at the 95th percentile, against 134 to 140 ms before. Every answer is the same.
First, less work for the same answer.
The first step changed no data structure. For a common word such as العمل, found in 38,269 articles, the query joined every candidate to all 103,112 laws to check that the law is served, sorted the candidates for duplicates that could not exist, and counted them all for a total the MCP never reads. Without that work, 30 real questions went from a p95 of 176 to 213 ms to 45 ms. When every word of a question is common, PostgreSQL had also chosen to read every ruling instead of using its index: 165 ms against about 25.
A file the build writes.
PostgreSQL has already split every text into words to build its search indexes. A build stage reads those words back and writes one list per word: the numbers of the texts that hold it, stored as small gaps. Beside the lists it writes what ranking needs for each text: its length, date, year and number. The file is 87.5 MB, 52 MB compressed, and takes 15 seconds to write on one CPU. It is made from the same database the image ships, and the server checks a fingerprint of that database before it uses the file.
Mapped, not loaded.
The server does not read the file into memory. It maps it: the operating system brings in the pages a search touches and can drop them again, as it does for PostgreSQL's own files. Only 12 MB of per-text columns are copied. The server is ready 70 to 110 ms after it starts, and searches the old way until then. A first version decoded everything into memory and held 200 to 225 MB. This one holds 51 to 61 MB, plus mapped pages the system can take back.
In memory or in the database?
We built it both ways. With the same lists stored in a PostgreSQL table and the same ranking code, CPU per search fell from 13 to 15 ms to 7 to 9 ms. Read from the mapped file, it fell to 4 to 6 ms. The table version pays 3 to 5 extra queries per search, and PostgreSQL must unpack and copy each list, which grows with the word. It also cost 432 MB in the database against an 88 MB file, and 133 seconds of build against 15. The database can hold the index. Reading it in the same process is what wins.
Exact to the bit.
A faster search that ranks differently would be a different search. We compared 21,169 answers field by field, with scores compared bit for bit, and none differed. That meant computing each score the way PostgreSQL does, multiplying in exact decimal before dividing in floating point. The obvious formula gave a different result in 692 of 2,000 values. Where the old query's own answer is not fixed, because texts of equal length sit on both sides of a cut-off, the index hands the search back to SQL. That happened for 4.9% of answers.
A mistake the live check caught.
Right after the deploy, one question took 3,154 ms. It is one of the searches the index hands back to SQL, and before handing it back the index had sorted all 102,605 matches for nothing. Counting the matches by length finds the same cut-off without a sort. That fix merged an hour later. We now time the cases that fall back too, not only the fast ones.
Live numbers.
Server time for six searches sent at once to the live site: 19 to 178 ms before, 17 to 90 ms after, once the container is warm. One second after the deploy, on a cold container: 104 to 298 ms, and no call refused. The first minute after the previous search deploy had 88 to 1,050 ms and one refusal.