← Project journal

One search engine for the website, the API and MCP

Since 7 November the website, the API and the MCP server share one search, run by PostgreSQL alone. The slowest of 30 real questions: 4,835 ms to 624 ms.

The short answer.

There were two searches: one for the website and the API, and one for the MCP server that kept copies of the texts in the server's memory. Since 7 November there is one, and it runs in PostgreSQL alone. On 30 real questions, on a copy of the data, the search page's median went from 401 to 296 ms and its slowest question from 4,835 to 624 ms.

Measure each tool, not the endpoint.

The MCP endpoint looked fine: a p95 of about 350 ms. That number counted every request, and most are handshakes and tool listings, about 41,000 a week. Per tool the picture was different. Over seven days search_legislation ran 785 times with a median of 230 ms, a p95 of 2.6 seconds and a p99 of 5.0 seconds. 185 calls took over a second and 12 timed out.

A fix we replaced the same night.

The first fix kept the in-memory copies and filled them when the server started. It made the first search faster and left the design alone: every deploy still began cold, and the memory still had to be filled. Less than four hours later it was gone. The rule we took from it: when something is slow because it is computed or loaded while serving, move the work to the build.

Word counts made by the build.

A good ranking weighs rare words more than common ones, so it must know how many texts hold each word. The database build now writes that into search_term, a materialized view, with each index's size and average text length. Each text's length is a stored column. A search reads those numbers and counts nothing. With them, the MCP's 30 real questions went from a median of 71 ms to 45 ms, and the slowest from 1,544 ms to 367 ms.

Every word first.

Texts that hold every word of the question come first. Then come texts missing only the commonest words, and the reply says partial_match: true. Each word also matches its stem and its form without a leading و or ف, in the same query: والخامسة finds الخامسة. Before, a question with no full match was retried up to a dozen times with different readings, one search after another.

Titles match what is written.

Titles no longer match by stem, because الغابات (forests) and الغابون (Gabon) share one. A title matches the word as written, without its leading article, or as the start of a longer word. The word written in the title counts more than a synonym or the kind of text, and the newest text wins a tie.

French without accents.

French search now ignores accents: ecrit finds écrit, on the website, the API and the MCP server. The unaccented form is a column the build writes, not something each search works out.

Sources

Search the library