Repository navigation
/search paging is unfinished: cost scales with depth, no rel="next", and pages under-fill #306
Description
Umbrella issue for the remaining /search paging work. The investigation this issue was opened from is preserved in the first comment below; the numbers here are re-measured on the current code.
Summary
/search still pages in the API process, still carries no rel="next", and still under-fills its pages. /query no longer does any of those things, so the two endpoints now behave differently for the same limit and skip.
The work left is carried by three sub-issues:
| Sub-issue | What it covers |
|---|---|
| #309 | Move pagination into MongoDB, and emit rel="next" for /search and /search/phrase |
| #313 | Stop serving — and stop half-dropping — documents whose _id is not a string, so a page fills with servable documents |
| #308 | Check the Atlas Search index definitions into the repository, a prerequisite for #309's single-index option |
#307 is closed. Its ranking bug is fixed, and its remaining dedup line moved to #313.
Baseline
Start from this repository with #312 merged, on which everything below was measured. /query is the reference implementation of the contract /search has to meet:
limitandskipare validated,limitis clamped, an over-maximumskipis a400, andPagination-Limit,Pagination-Skip,Pagination-Limit-MaxandPagination-Skip-Maxare reported —limitandskipsilently guess at invalid input, and an over-maximumskipreturns the same page forever #301./searchalready has all of this, becausegetPagination()is shared.rel="next"is appended next to the JSON-LD context link while another page exists, built bysetNextPageLink(req, res, { limit, skip, hasNext })incontrollers/utils.js—/querypaged responses carry norel="next", so no client can tell a full page from the last page #302. Reuse that helper rather than writing a second one.hasNextcomes from over-fetching one record (limit + 1) and trimming it before serialization —/querypaged responses carry norel="next", so no client can tell a full page from the last page #302.
What /search does today
Measured 2026-09-17, read-only, against localhost:3001 reading the dev collection.
Cost scales with depth, and an empty page past the end is the most expensive request of all. {"searchText":"line"} at limit=100, against /query over a filter of comparable breadth:
skip |
/search |
/query |
|---|---|---|
| 0 | 100 documents in 491ms | 100 documents in 100ms |
| 1000 | 100 documents in 771ms | — |
| 2000 | 100 documents in 938ms | — |
| 5000 | 0 documents in 3130ms | 100 documents in 115ms |
Each branch caps at $limit: limit + skip (controllers/search.js:209,214), then Node merges, deduplicates, sorts and slices. /query hands the same work to Mongo.
No rel="next". The pagination headers are there; the link is not:
POST /v1/api/search?limit=100 {"searchText":"line"}
-> 200
Pagination-Limit: 100 Pagination-Skip: 0 Pagination-Limit-Max: 500 Pagination-Skip-Max: 100000
Link: <http://store.rerum.io/v1/context.json>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
A /search client is left counting records, which is exactly what #302 removed for /query — and counting is unreliable here, because pages under-fill:
Pages under-fill, and one unusable document survives per response. {"searchText":"manuscript"} at limit=100 returns 50 documents against the 68 raw matches measured in the original investigation. Of those 50, exactly one has @id http://165.134.105.29/annotationstore/annotation/[object Object] — the arbitrary survivor of the dedup key collapsing every embedded-object _id onto one string (controllers/search.js:37). Per the 2026-09-17 ruling, those documents need not be served at all; #313 excludes them in the pipeline so the page fills with documents a client can use.
Already fixed — do not re-report
- Relevance ranking.
mergeSearchResults()sorts on__rerum?.scoreas ofbd91fc7, merged in Behavioral Fixes forlimitandskip#310, with regression tests atroutes/__tests__/search.test.js:126and:141. Re-verified 2026-09-17:line,text,manuscriptandpageatlimit=500each return their best match first, with zero score increases, and an 8-page walk oflinehas zero increases across all 3825 records./search/phraseshows the same. limit/skipvalidation and thePagination-*headers on/search—limitandskipsilently guess at invalid input, and an over-maximumskipreturns the same page forever #301, merged in Behavioral Fixes forlimitandskip#310.- The two one-line mitigations proposed in the original investigation. The first shipped. The second — re-keying the dedup on
JSON.stringify(result._id)— is explicitly not wanted: it would preserve documents that do not need serving. Objects whose_idis not a string are served by/queryand dropped by accident in/search#313 excludes them instead.
Notes
- Order of work. Objects whose
_idis not a string are served by/queryand dropped by accident in/search#313 is independent and can land at any time./searchpaginates in application memory, so every page costs the server everything up to that page #309 needs Atlas Search index definitions are not in the repository #308 for its single-index option. If/searchpaginates in application memory, so every page costs the server everything up to that page #309 restructures first, Objects whose_idis not a string are served by/queryand dropped by accident in/search#313's/searchcondition belongs in its one pipeline rather than in two$matchstages. rel="next"for/searchwas deliberately kept out of/queryoptimization #312. The link contract survives/searchpaginates in application memory, so every page costs the server everything up to that page #309; only thehasNextover-fetch changes shape. It should not ship while pages still under-fill, because anextlink on a short page tells a client the page was full of servable records when it was not.- Documentation.
public/API.htmlandopenapi/contracts/core-provider.openapi.yamldescribe/api/searchand/api/search/phrasewith no paging parameters and no ordering statement. The pagination contract is undocumented, misdocumented, and absent from the OpenAPI contract #305 owns the contract documentation for both endpoints;/searchpaginates in application memory, so every page costs the server everything up to that page #309 carries the/searchspecifics. - A cursor for
/search— Atlas SearchsearchAfter/searchSequenceToken— only makes sense after/searchpaginates in application memory, so every page costs the server everything up to that page #309, and its availability on our cluster tier and server version has to be confirmed first. Add cursor-based continuation to/queryso paging depth is unbounded and cost is flat #303 covers the/querycursor and is deferred. - Reading dev measurements. Dev walks show distinct documents sharing an
@idfrom the v0 import, about 30 repeats within a single page. That is data, not a paging defect.
Acceptance criteria
This issue closes when its sub-issues do and the endpoint behaves like /query:
- Atlas Search index definitions are not in the repository #308,
/searchpaginates in application memory, so every page costs the server everything up to that page #309 and Objects whose_idis not a string are served by/queryand dropped by accident in/search#313 are closed -
/searchand/search/phraselatency is flat with respect toskip, comparable to/queryat the same depth, measured with the probes above -
/searchand/search/phrasecarryrel="next"while more results exist, next to the JSON-LD context link, built bysetNextPageLink() - A client following only
rel="next"walks a result set to completion and terminates, with no length-based stop condition - Every page fills to the applied
limitwhile matches remain, and the documents it contains are servable - Sequential paged results equal a single larger request, in the same order
- Relevance ordering is preserved across page boundaries, verified by asserting the score sequence never increases across a full walk
- The unmounted
searchFuzzily,searchWildlyandsearchAlikesmatch whatever lands, so the pattern is not carried forward without them - The paging and ordering contract for both search routes is stated in
public/API.htmland inopenapi/contracts/core-provider.openapi.yaml
Activity
/searchmerge sorts on a field that does not exist and drops documents whose_idis an embedded object #307/searchpaginates in application memory, so every page costs the server everything up to that page #309thehabes commentedon Sep 17, 2026
Original investigation, verified 2026-09-02 and re-run 2026-09-03. This was the body of this issue until 2026-09-17, kept here because the evidence is still the source for the raw match counts. Superseded by the current body: the ranking bug it describes is fixed (#310), and the dedup fix it proposes is not wanted (see #313).
Full report as written
Detailed verification report. This is the full investigation this issue and its sub-issues were written from.
Summary
/searchfans out to two Atlas Search indexes as two separatedb.aggregate()calls, then merges, deduplicates, sorts, and slices the combined result in Node (controllers/search.js:282). Three problems follow from that design, two of them user-visible:0for every pair and the sort is a no-op. Results come back as "every IIIF 3.0 hit, then every IIIF 2.1 hit," regardless of score. On production the single best-scoring match forline— scoring 84 — is ranked 6th, behind five matches scoring under 2._id?.$oid || _id?.toString(). Legacy documents whose_idis an embedded object (the v0 import from165.134.105.29/annotationstore) all key as the string"[object Object]", so the first one wins and every other one is silently discarded. On the dev collection, 182 of the 4007 documents matchinglineare unreachable through/searchon any page./querypushes pagination down to Mongo —db.find(props).limit(limit).skip(skip)(controllers/crud.js:87)./searchcannot, so every page costs the server everything up to that page, on every request.Nothing about
$searchrequires any of these. A single Atlas Search pipeline sorts by score natively, returns each document once, and can end in$skip/$limit. The blocker is the two-index fan-out, not the search stage — which is why one change fixes all three.The pagination contract problems shared by both endpoints — silent clamping, the repeating page past the
skipmaximum, the inert environment variables — are tracked separately in the paged-reads issue. This issue is about/search's own internals.Why this matters
The ranking bug
buildDualIndexQueries()writes each document's relevance score to__rerum.score(controllers/search.js:210,215):mergeSearchResults()then sorts on a top-levelscore(controllers/search.js:45):No document has a top-level
score. Every comparison evaluates0 - 0, the sort does nothing, and the merged array keeps its construction order —[...results1, ...results2], IIIF 3.0 first. The JSDoc on both search handlers promises results "sorted by relevance score (highest first)". They are not, and have not been.This is not subtle in practice. On production, searching
linereturns five IIIF 3.0 matches scoring between 1.69 and 2.25, and then at index 5 the score jumps to 83.9 — the IIIF 2.1 boundary and the best match in the entire set, ranked behind five documents that score fifty times lower. On the dev collection, where the IIIF 3.0 index has 205 hits for the same term, the best match (score 12.0) is ranked 206th; a client showing "top 10 results" never sees it, and a client paging at 100 per page finds it on page 3.Any deployment where one vocabulary dominates the corpus will see the other vocabulary's matches effectively buried, no matter how well they score.
The dropped documents
mergeSearchResults()(controllers/search.js:37) derives its deduplication key as:That is correct for an
ObjectIdand for a string. It is wrong for a plain object:({}).toString()is"[object Object]". The dev collection holds 6427 documents whose_idis an embedded object — the serialized JavaObjectIdshape ({date, inc, machine, time, …}) left behind by the v0 import. Every one of them that matches a search collapses onto the same key, the first is kept, and the rest are dropped before paging begins.Measured directly against the two branch pipelines on the dev collection at full depth (
$limit100100 per branch):presi3rawpresi2raw_idlinetextmanuscriptpageTwo consequences for a paging client:
skipvalue ever returns the 182 droppedlinedocuments. A client that walks/searchto the empty page believes it has everything.manuscriptwith the defaultlimit=100andskip=0returns 50 documents from 68 matches. Alength < limitclient concludes there are 50 results and stops. This is the hazard the per-branch$limit-before-dedup shape always had; it is not hypothetical.Cross-index overlap, by contrast, really is zero for every term tried — a document is written in either IIIF 3.0 or IIIF 2.1 shape, and the two indexes cover disjoint field paths. The deduplication is not doing the job it was written for; it is doing a different, harmful one.
Production currently has no embedded-object
_iddocuments ({"_id.inc":{"$exists":true}}returns nothing onstore.rerum.io), so the drop is not observable there today. It is a defect in the code path regardless: any document whose_idis neither anObjectIdnor a string will be lost the same way, and the dev deployment is where clients are developed against.The paging cost
Cost scales with depth, not page size. Each branch caps at
$limit: limit + skip(controllers/search.js:211,216), so a request for page 40 makes Atlas produce and return 4000 documents per index, and makes Node sort and deduplicate up to 8000 of them, in order to hand back 100./queryat the same depth asks Mongo to skip and returns 100.The ceiling is set by the
skipmaximum. With the effective maximum at 100000 (see the paged-reads issue — the configured 10000 is not in effect on any deployment), a sufficiently broad search term at maximum depth asks Node to hold on the order of 100000 documents per branch in memory and sort them. Full RERUM documents, not identifiers. The two issues compound: the cap that is too high is also the cap that bounds this blast radius.It is a shared-process cost. This work happens in the API process, not in Atlas. Under PM2 cluster mode a few concurrent deep searches contend for the same workers that serve every other endpoint.
Evidence
Verified 2026-09-02. "Dev" means the local pm2 instance at
localhost:3001, which reads the same collection asdevstore.rerum.io(thererum-testcluster,annotationStore.alpha); the same numbers were confirmed ondevstore.rerum.iowhere noted. "Prod" meanshttps://store.rerum.io. All probes are read-only.Independently re-run twice on 2026-09-03, read-only, against
localhost:3001and directly against thepresi3AnnotationText/presi2AnnotationTextindexes (bypassing the endpoint to isolate the merge step), with the score-ordering check also re-run againstdevstore.rerum.ioandstore.rerum.io. Every number reproduced: theline/text/manuscript/pageraw and dropped counts, the score jump forlineat index 205 (1.958 → 12.013, rank 206th of 500 returned) on both local and devstore, the identical boundary on/search/phrase, themanuscriptunder-fill (68 raw matches, 50 returned atlimit=100), the empty pages forlineatskip=100000/100100/150000, the 6427 embedded-object_iddocuments on dev against none on production, and the tiling and latency-scaling shapes. No document keyed on any embedded-object_idwas found to collide with a distinct document's key by anything other than the"[object Object]"collision already described — the mechanism is exactly as diagnosed. One number moved: production's bestlinescore now reads 83.928 rather than 83.925, which is ordinary drift as the corpus and index change and does not affect the rank.Ranking is broken, measured through the endpoint
The results of a single
/search?limit=500request, read back via.__rerum.score. A correctly sorted list never increases.linetextpagelineIn every case there is exactly one increase, and it sits exactly at the boundary between the two branches.
/search/phraseshows the identical boundary (dev,line: index 205, 1.958 → 12.013). Confirmed at the source by running the pipelines directly: every returned document has__rerum.scorepopulated and top-levelscoreundefined, so(b.score || 0) - (a.score || 0)is0for every pair.Documents are dropped by the merge
The table in "The dropped documents" above. Confirmed by grouping raw branch output by the merge's own key: for
manuscript, 68 raw results yield 50 distinct keys, and the one colliding key —"[object Object]"— has 19 members, each a different document with a different_id, all@type: oa:Annotationfrom the v0 import.Confirmed a second way, end to end through the endpoint rather than against the pipelines. Walking
/searchforlineatlimit=500until it returns an empty page reaches exactly 3825 records — the merged count, not the 4007 raw matches. The 182 dropped documents are not on a later page; they are not on any page:The under-fill is visible in a single request with no walking at all:
A
length < limitclient stops right there and reports 50 of 68.Latency scales with depth
Dev,
{"searchText":"line"},limit=100:/queryat the same depths for contrast,{"__rerum.APIversion":{"$exists":true}},limit=100:The two seconds spent to return nothing at
skip=5000is the cost of producing and merging the entire matching set. Cost plateaus once the result set is exhausted, which confirms the bound ismin(total matches, limit + skip)per branch — for a broad term on a large collection, that bound is theskipmaximum. Prod shows the same shape at a smaller scale.linehas 574 prod matches:skip=0returns 100 in 621 ms,skip=500returns the last 74 in 941 ms, and the empty pages atskip=1000,2000, and20000still cost roughly 850–900 ms each.Paging is self-consistent, even though the ordering is wrong
Five sequential
limit=100pages return exactly the same 500 records in exactly the same order as a singlelimit=500request. The tail terminates:skip=3700returns 100,skip=3800returns 25,skip=3900returns 0 — 3825 records reachable, which is exactly the merged count after the 182 drops.Worth being precise about why: each branch returns a deterministic prefix of its own result set, the caps grow with
skip, and the no-op sort leaves the concatenation order stable. So pages tile correctly — membership and page boundaries are stable. They are tiling a list that is in the wrong order and that is missing documents, and no client can page its way to either the best match or the dropped ones.Past the
skipmaximum,/searchreturns an empty page rather than the repeated page described in the paged-reads issue, because the slice runs off the end of the merged set:line,the, andofall return 0 documents atskip=100000,100100, and150000on dev. The repeated page would need a term with more than 100000 merged matches, which no term tried reaches.Affected lines
controllers/search.jsbuildDualIndexQueries()builds two independent pipelinescontrollers/search.jspresi3AnnotationText,presi2AnnotationTextcontrollers/search.js$limit: limit + skipper branch — the depth costcontrollers/search.js__rerum.scorecontrollers/search.js_id?.$oid || _id?.toString()collapses embedded-object_idvalues to"[object Object]"— the drop bugcontrollers/search.jsscore, which is never set — the ranking bugcontrollers/search.jsmergeSearchResults()deduplicates in Node, after the per-branch capcontrollers/search.jscontrollers/search.jsmerged.slice(skip, skip + limit)— pagination in application memorycontrollers/search.jssearchAlikes()controllers/crud.js/queryfor contrast:.limit(limit).skip(skip)in Mongoopenapi/contracts/core-provider.openapi.yaml/api/searchand/api/search/phrasedeclare nolimitorskipparameter and no ordering guaranteeLive endpoints are
searchAsWords(:263) andsearchAsPhrase(:346), mounted at/searchand/search/phrase(routes/search.js:6,13).searchFuzzily(:420),searchWildly(:510), andsearchAlikes(:614) have the same structure but no routes yet — worth fixing together so the pattern is not carried forward when they are mounted.Proposed change
Three options, best first.
1. One combined Atlas Search index (recommended)
Define a single index covering both vocabularies — the IIIF 3.0 paths (
body.value,bodyValue, and theitems/annotationsembedded documents) and the IIIF 2.1 paths (resource.chars,resource.cnt:chars, and theresources/otherContent/sequencesembedded documents)./searchthen becomes one$searchwith all the existingshouldclauses, followed by$skipand$limit. This fixes all three problems at once:$searchresults in descending score order across all clauses, so there is no cross-branch merge to get wrong.mergeSearchResults()can be deleted, and with it both bugs.Cost: an Atlas-side index change, and both indexes must exist during the transition.
2.
$unionWithin a single pipelineKeep both indexes, run the IIIF 2.1 branch as a
$unionWithsub-pipeline against the same collection, then$groupon_idto deduplicate,$sortby score, and$skip/$limit— all server-side. Grouping on_idin Mongo compares the value structurally, so embedded-object ids group correctly there.This removes the work from the API process but not from the cluster: the
$groupis a blocking stage over the union, so Atlas still materializes the merged set. It is a smaller change than option 1 with a smaller payoff. Before committing to it, confirm that$searchis permitted as the first stage of a$unionWithsub-pipeline on our cluster tier.3. Keep the current shape, bound the damage
If neither restructuring is scheduled soon, the depth cost can at least be capped by enforcing a
/search-specific maximum onskipthat is far below the/querymaximum, since the two endpoints pay very different prices for the same depth. This is a mitigation, not a fix, and it makes the two endpoints inconsistent — which is exactly what the paged-reads issue argues against — so it should be a deliberate, documented decision rather than a default.Notes
openapi/contracts/core-provider.openapi.yaml) describes/api/searchand/api/search/phraseas taking a body and returning aGenericArray, with nolimitorskipparameter and no statement about ordering. Whatever ordering guarantee comes out of this work should be written there, not only in the JSDoc — the paged-reads issue covers the parameters themselves.__rerum.scoreinstead ofscoreinmergeSearchResults(), which makes cross-index ordering real without touching the index layout;_idshape —JSON.stringify(result._id), or the document's@id/id— instead oftoString().Both change results for existing clients (order for everyone; membership on dev), so they are behavioral even though the diffs are trivial.
rel="next"work proposed in the paged-reads issue needs/searchto over-fetch one record per branch (limit + skip + 1) to know whether another page exists. That applies under any of the three options here, and under option 1 it becomes the ordinary single-pipelinelimit + 1.Acceptance criteria
/searchresults are ordered by descending relevance score across both vocabularies, verified by asserting the returned score sequence never increases_idis an embedded objectmergeSearchResults()(or its replacement) documents withObjectId, string, and embedded-object_idvalues and asserts none are dropped/searchpagination is applied in MongoDB rather than in application memory/searchlatency at depth is flat with respect toskip, comparable to/query/searchand/search/phraseboth covered; the unmounted search variants updated to matchopenapi/contracts/core-provider.openapi.yaml, not only in JSDoc[-]`/search` misranks results, drops matching documents, and pages in application memory[/-][+]`/search` paging is unfinished: cost scales with depth, no `rel="next"`, and pages under-fill[/+]_idis not a string are served by/queryand dropped by accident in/search#313