Skip to content

fix(search): relevance ranking, per-version de-dupe, product-scoped version filter#1227

Open
aidenhongnet wants to merge 8 commits into
netwrix:devfrom
aidenhongnet:search-fix
Open

fix(search): relevance ranking, per-version de-dupe, product-scoped version filter#1227
aidenhongnet wants to merge 8 commits into
netwrix:devfrom
aidenhongnet:search-fix

Conversation

@aidenhongnet

@aidenhongnet aidenhongnet commented Jul 14, 2026

Copy link
Copy Markdown

Problem

The full search page re-sorted every Algolia hit alphabetically by product before paginating, so the top-ranked result for a query could land pages deep while low-scoring hits from alphabetically-early products always filled page one. The Ctrl+K preview had the same problem from a different cause: DocSearch buckets hits by hierarchy.lvl0, which the crawler sets to "Product > Version", so the preview grouped by product and dropped results past a per-group cap. Both surfaces hid Algolia's ranking.

Two further problems on the same surfaces (second round of commits): the same article appeared once per product version — the index legitimately stores one record set per (product, version) copy of a page, and both surfaces rendered them all — and the Versions filter rendered even with no product selected; on /search it listed the union of all 27 products' versions, which is meaningless without a product.

What changed

Search results page — now server-side, relevance-ordered.

  • Renders Algolia's relevance order directly. The alphabetical-by-product sort is gone; product filtering stays available via the product/version facet dropdowns.
  • Replaced the fetch-1000-hits-and-paginate-client-side design with Algolia server-side pagination (hitsPerPage = page size, setPage(n)). Per-search payload dropped from ~4.7 MB to ~170 KB.
  • A stale/overshooting ?page=N recovers to the first page instead of rendering an empty, control-less dead end.
  • Banner reflects the reachable cap: "the top 1,000 of N — refine" when more match than Algolia will page to, exact count otherwise.

Preview (Ctrl+K modal) — now relevance-ordered.

  • ungroup() collapses DocSearch's two grouping passes (hierarchy.lvl0 and hierarchy.lvl1) so hits render in Algolia's order; the real title is fed back through _snippetResult (Algolia-escaped, highlighting preserved) and the product is shown per hit.
  • This is a client-side shim around the crawler putting the product in lvl0. The root-cause fix is to set lvl0 to a constant in the Algolia crawler config, after which the shim can be deleted — noted in-code.

Version de-dupe — one row per article, newest version wins (both surfaces).

  • Each fetched page collapses duplicate articles to the version-newest copy, in its original ranking slot. Identity = the hit's URL minus #fragment minus its version path segment, with trailing-slash variants normalized, standalone /docs/kb/... pages rewritten to their versioned-copy shape, and version-pinned KB source basenames (kbSource overrides, e.g. accessanalyzer-2601) normalized to the product id.
  • "Newest" is config order in products.js (versions[0] is the unique isLatest for all 27 products) — digit parsing is provably wrong here (passwordreset's latest 3.3 predates legacy hidden 3.23 numerically). Versions missing from config rank oldest.
  • Selecting any specific version disables de-dupe — per-version rows return, restricted to the selected versions. The SearchPage gates on the facet carried by the response's own request, so in-flight filter changes can never mis-gate.
  • New shared src/theme/searchUtils.js holds the de-dupe and version-list logic; it replaces two diverged local copies of getVersionsForProducts.

Version filter — only meaningful with a product selected (both surfaces).

  • The Versions dropdown renders only while at least one specific product is selected, listing only the selected products' versions, newest first.
  • The same rule gates the query facet and the versions URL param, so stale bookmarked URLs or leftover sessionStorage can never filter invisibly while the panel is hidden.
  • The modal passes an extended attributesToRetrieve (DocSearch replaces its defaults wholesale, so they are re-listed alongside product_name/product_version — pinned with a re-verify note for future DocSearch bumps).

Adjacent fixes on the same surfaces.

  • Re-enabled typo tolerance (was hard-disabled on both surfaces; any misspelling returned zero results).
  • Added distinct: true to the page helper (deduped near-duplicate hits; matches the modal).
  • Removed advancedSyntax (a query like Get-ADUser -Filter silently excluded docs containing "Filter").
  • Moved the Algolia result listener into a useEffect with cleanup (was re-registering every render with stale closures); added a real debounce; fixed a <meta property="robots">name="robots" typo.
  • Deleted dead code the refactor surfaced: the config transformItems (never serialized), an unused clsx import, an unused CSS module, and disjunctiveFacets.

Verification (de-dupe/filter commits)

All nine planned dev-server flows pass: modal and /search de-dupe, product-scoped version lists, version selection restoring per-version rows, stale versions= URL scrubbing with de-dupe intact, modal→page filter handoff, and identitymanager (current wins), passwordreset (3.3 beats legacy 3.23), and KB standalone/versioned merge spot-checks. A standalone logic suite exercises the de-dupe key against the real products.js. An adversarial multi-agent review of the diff confirmed one finding (the pinned-KB landing slug divergence), which is fixed in the second commit.

Known follow-ups (not in this PR)

  • KB pages under /docs/kb/{general,recoveryad,tags}/ have empty product_name in the index and badge as "Documentation" — a crawler/index gap.
  • The ungroup shim depends on DocSearch internals; a future @docsearch/react major upgrade could silently revert the preview to product order (documented in-code with a re-check note). The crawler lvl0 fix removes this dependency entirely.
  • Cross-product KB duplicates (the same KB article copied into different products) remain separate results — distinct product docs; the product filter covers it. Likewise the legacy shared docs/kb/recoveryad standalone set, which backs two products and has no unambiguous merge target.
  • nbHits/banner counts still count all versions (fixing that needs global knowledge of the result set; de-dupe is per fetched page by design).

🤖 Generated with Claude Code

The search page re-sorted every Algolia hit alphabetically by product name
before paginating, so the top-ranked hit for a query could land pages deep
while low-scoring hits from alphabetically-early products always filled page
one. Product grouping is now an opt-in view (?group=1) that re-sorts the
already-fetched hits client-side, with no extra Algolia request; the default
is Algolia's relevance order, with a product/version badge on each hit so the
context the grouped headings used to provide is still visible.

Also fixed, each of which suppressed or distorted results:

- Typo tolerance was disabled on both the search page and the modal, so any
  misspelling returned zero results. Now uses the index default.
- distinct was missing on the search page helper though the modal already set
  it, letting one document appear as many near-duplicate hits.
- advancedSyntax was enabled on the page only, so the page and modal answered
  the same query differently, and a query containing a CLI flag such as
  "Get-ADUser -Filter" silently excluded every doc containing "Filter".
  Removed, for parity with the modal.
- The Algolia result listener was registered during render, adding a listener
  per re-render. Each stale listener fired with a stale resultsPerPage and
  sliced pages wrong. Now registered once in an effect, with cleanup.
- The 300ms search debounce never cleared its timeout, so every keystroke
  fired a full 1000-hit query.
- Version extraction treated the first path segment as a version for
  single-version products, yielding badges like "1Secure admin".
- The robots meta used property= instead of name=, so crawlers ignored the
  noindex on the search results page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
aidenhongg and others added 4 commits July 14, 2026 13:59
DocSearch groups hits by hierarchy.lvl0 and caps each group at
maxResultsPerGroup (default 5). The Algolia crawler sets lvl0 to
"Product > Version", so the preview bucketed results per product: a
later hit from an already-seen product was hoisted above higher-ranked
hits from other products, and anything past the fifth hit in a product
was dropped without trace. For "install agent" that meant 7 groups and
18 of 20 hits shown, in an order Algolia never returned.

Collapsing lvl0 to a single value leaves one group, so hits render in
the order Algolia ranked them, and maxResultsPerGroup now caps the whole
list rather than each product.

This is the crawler's `lvl0: {defaultValue: ...}` applied client-side.
The real fix is to stop putting the product in lvl0 in the crawler
config, after which this can be deleted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… preview

Rework both search surfaces so results are ordered by Algolia's ranking
score, and drop the machinery that made that expensive.

Search page:
- Replace the fetch-1000-hits-and-paginate-client-side design with Algolia
  server-side pagination (hitsPerPage = page size, setPage(n)). This cuts
  the per-search payload from ~5.4 MB to ~170 KB and deletes allItemsRef,
  the client-side slicing, and the re-slice effect.
- Remove the opt-in "Group by product" toggle: the default is now Algolia's
  relevance order, and product filtering is still available via the product
  facet dropdowns. Deleting it is what lets pagination go server-side.
- A restored or stale ?page=N that overshoots the result set recovers to the
  first page instead of rendering an empty, control-less dead end.

Preview (Ctrl+K modal):
- DocSearch buckets hits by hierarchy.lvl0 (which the crawler sets to
  "Product > Version") and, within that, by hierarchy.lvl1, then caps each
  group. That reordered hits away from relevance and dropped a product's hits
  past the cap. ungroup() collapses lvl0 to one section and makes lvl1 unique
  per hit so neither pass reorders; the title is fed back through
  _snippetResult (Algolia-escaped, highlighting preserved) and the product is
  shown per-hit. maxResultsPerGroup is raised so the single group isn't capped
  at five. This is a client-side shim around the crawler's lvl0; fixing lvl0 in
  the crawler config lets the whole shim be deleted.

Also delete dead code the above surfaced: the config transformItems (never
survived themeConfig serialization) and its now-unreachable branch, an unused
clsx import, the unused SearchPage styles.module.css, and disjunctiveFacets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Algolia caps pagination at ~1000 hits (paginationLimitedTo) and nbHits is an
estimate, so "13,477 documents found" implied more was reachable than the
pages allow. When fewer results are reachable than match, show "the top N of
M matches — refine your search" instead; otherwise show the exact count. This
restores guidance that the server-side pagination change had dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aidenhongnet aidenhongnet reopened this Jul 15, 2026
@aidenhongnet aidenhongnet changed the title fix(search): rank results by relevance instead of product name fix(search): rank both search surfaces by relevance score Jul 15, 2026
aidenhongg and others added 2 commits July 16, 2026 12:27
…o products

Same article no longer appears once per product version: both surfaces now
collapse each fetched page to the newest version present (config order, not
digit parsing — passwordreset 3.3 outranks legacy 3.23), keyed by the hit's
URL minus fragment/version segment, with trailing-slash and standalone-KB
shapes normalized. De-dupe turns off whenever a real version filter is active.

The Versions filter now renders only while a specific product is selected and
lists only that product's versions (shared getVersionsForProducts in the new
src/theme/searchUtils.js replaces the two diverged copies). makeSearch and the
URL writer enforce the same rule, so stale versions from bookmarked URLs or
sessionStorage can never filter invisibly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The accessanalyzer-2601 pinned KB source publishes under its directory
basename instead of the product id — as the standalone route segment and as
the copied landing page's slug — so the KB landing still rendered one row per
version. articleKey now normalizes pinned basenames (built from the config's
kbSource overrides) to the product id, collapsing all five live shapes of the
landing to the newest version's copy.

Found by adversarial review against the live index; verified on the dev
server: /search?q=Access Analyzer Knowledge Base renders one row (2601).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@aidenhongnet aidenhongnet changed the title fix(search): rank both search surfaces by relevance score fix(search): relevance ranking, per-version de-dupe, product-scoped version filter Jul 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants