From 5da854f09ca169ed4e2d1cd1fb1881cf35ec841b Mon Sep 17 00:00:00 2001 From: Johan Preynat Date: Tue, 25 Aug 2026 12:54:24 +0200 Subject: [PATCH] Preserve backend ranking in published search (#4537) --- .../preserve-published-search-ranking.md | 5 ++ .../search/orderSearchResults.test.ts | 51 ++++++++++++ .../~gitbook/search/orderSearchResults.ts | 34 ++++++++ .../[siteData]/~gitbook/search/route.ts | 55 ++++++------- .../Search/SearchPageResultItem.tsx | 40 ++++++---- .../Search/combineRemoteResults.test.ts | 73 +++++++++++++++++ .../components/Search/combineRemoteResults.ts | 45 +++++++++++ .../Search/getPageResultHref.test.ts | 79 +++++++++++++++---- .../components/Search/getPageResultHref.ts | 41 ++++++++-- .../Search/reciprocalRankFusion.test.ts | 9 ++- .../components/Search/reciprocalRankFusion.ts | 12 ++- .../src/components/Search/search-types.ts | 6 +- .../src/components/Search/useSearchResults.ts | 17 +--- 13 files changed, 376 insertions(+), 91 deletions(-) create mode 100644 .changeset/preserve-published-search-ranking.md create mode 100644 packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/orderSearchResults.test.ts create mode 100644 packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/orderSearchResults.ts create mode 100644 packages/gitbook/src/components/Search/combineRemoteResults.test.ts create mode 100644 packages/gitbook/src/components/Search/combineRemoteResults.ts diff --git a/.changeset/preserve-published-search-ranking.md b/.changeset/preserve-published-search-ranking.md new file mode 100644 index 000000000..cfe2ebed8 --- /dev/null +++ b/.changeset/preserve-published-search-ranking.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Preserve canonical backend ranking and present page or section context that matches each published search destination. diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/orderSearchResults.test.ts b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/orderSearchResults.test.ts new file mode 100644 index 000000000..7f64050a5 --- /dev/null +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/orderSearchResults.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'bun:test'; + +import { orderSearchResultGroups } from './orderSearchResults'; + +describe('orderSearchResultGroups', () => { + it('reconstructs canonical page ranks across space groups before context records', () => { + const results = orderSearchResultGroups([ + { + type: 'pages', + results: [ + { + rank: 4, + result: { title: 'Snyk CLI documentation', score: 42, rank: 4 }, + }, + { + rank: 2, + result: { title: 'Types of automations', score: 12, rank: 2 }, + }, + ], + }, + { + type: 'context', + results: [{ title: 'Snyk integration context', score: 100, rank: undefined }], + }, + { + type: 'pages', + results: [ + { + rank: 1, + result: { title: 'Snyk CLI', score: 1, rank: 1 }, + }, + { + rank: 3, + result: { title: 'Method URL', score: 24, rank: 3 }, + }, + ], + }, + ]); + + expect(results.map(({ title }) => title)).toEqual([ + 'Snyk CLI', + 'Types of automations', + 'Method URL', + 'Snyk CLI documentation', + 'Snyk integration context', + ]); + expect(results[0]?.score).toBe(1); + expect(results[3]?.score).toBe(42); + expect(results.map(({ rank }) => rank)).toEqual([1, 2, 3, 4, undefined]); + }); +}); diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/orderSearchResults.ts b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/orderSearchResults.ts new file mode 100644 index 000000000..4d8b579fa --- /dev/null +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/orderSearchResults.ts @@ -0,0 +1,34 @@ +type RankedPageResult = { + rank: number; + result: TResult; +}; + +export type SearchResultGroup = + | { type: 'pages'; results: RankedPageResult[] } + | { type: 'context'; results: TResult[] }; + +/** Reconstruct the backend's global page order after it grouped results by space. */ +export function orderSearchResultGroups(groups: SearchResultGroup[]): TResult[] { + const pages: (RankedPageResult & { inputOrder: number })[] = []; + const context: TResult[] = []; + + for (const group of groups) { + if (group.type === 'pages') { + const inputOffset = pages.length; + pages.push( + ...group.results.map((entry, index) => ({ + ...entry, + inputOrder: inputOffset + index, + })) + ); + } else { + context.push(...group.results); + } + } + + pages.sort((left, right) => { + return left.rank - right.rank || left.inputOrder - right.inputOrder; + }); + + return [...pages.map(({ result }) => result), ...context]; +} diff --git a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/route.ts b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/route.ts index f0658ab8c..e93986e63 100644 --- a/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/route.ts +++ b/packages/gitbook/src/app/sites/dynamic/[mode]/[siteURL]/[siteData]/~gitbook/search/route.ts @@ -9,6 +9,7 @@ import type { } from '@gitbook/api'; import type { IconName } from '@gitbook/icons'; +import { orderSearchResultGroups } from './orderSearchResults'; import type { ComputedPageResult, ComputedSectionResult, @@ -51,8 +52,8 @@ export async function POST(request: NextRequest) { ), ]); - const results = searchResults - .flatMap((resultItem) => { + const results = orderSearchResultGroups( + searchResults.map((resultItem) => { if (resultItem.type === 'record') { const result: OrderedComputedResult = { type: 'record', @@ -63,7 +64,7 @@ export async function POST(request: NextRequest) { score: resultItem.score, }; - return [{ score: resultItem.score, items: [result] }]; + return { type: 'context' as const, results: [result] }; } const found = findSiteSpaceBy( @@ -71,21 +72,23 @@ export async function POST(request: NextRequest) { (siteSpace) => siteSpace.space.id === resultItem.id ); - return resultItem.pages.map((pageItem) => ({ - score: pageItem.score, - items: transformSitePageResult({ - asEmbeddable: Boolean(asEmbeddable), - linker: context.linker, - pageItem, - spaceItem: resultItem, - siteSpace: found?.siteSpace, - siteSection: found?.siteSection ?? undefined, - siteSectionGroup: found?.siteSectionGroup ?? undefined, - }), - })); + return { + type: 'pages' as const, + results: resultItem.pages.map((pageItem) => ({ + rank: pageItem.rank, + result: transformSitePageResult({ + asEmbeddable: Boolean(asEmbeddable), + linker: context.linker, + pageItem, + spaceItem: resultItem, + siteSpace: found?.siteSpace, + siteSection: found?.siteSection ?? undefined, + siteSectionGroup: found?.siteSectionGroup ?? undefined, + }), + })), + }; }) - .sort((a, b) => b.score - a.score) - .flatMap((group) => group.items); + ); return NextResponse.json(results); } @@ -98,7 +101,7 @@ function transformSitePageResult(args: { siteSpace?: SiteSpace; siteSection?: SiteSection; siteSectionGroup?: SiteSectionGroup | null; -}): OrderedComputedResult[] { +}): OrderedComputedResult { const { asEmbeddable, pageItem, spaceItem, siteSection, siteSectionGroup, siteSpace, linker } = args; const currentLanguage = siteSpace?.space.language; @@ -145,22 +148,17 @@ function transformSitePageResult(args: { ? toEmbeddableLinkForPublishedContent(linker, spaceURL, pageItem.path) : linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path)); - // The deployed API already returns this field, but older generated clients and responses do not. - const resultType = - 'resultType' in pageItem && - (pageItem.resultType === 'page' || pageItem.resultType === 'section') - ? pageItem.resultType - : undefined; - const page: ComputedPageResult = { type: 'page', id: `${spaceItem.id}/${pageItem.id}`, title: pageItem.title, + description: pageItem.description, href: pageHref, pageId: pageItem.id, spaceId: spaceItem.id, score: pageItem.score, - resultType, + rank: pageItem.rank, + resultType: pageItem.resultType, breadcrumbs, }; @@ -196,8 +194,7 @@ function transformSitePageResult(args: { }; }) ?? []; - // The search API returns each page's sections ordered highest-score-first and caps them at one - // per page, so the first section is the best-scoring one to use as a body preview. + // The API returns at most one section per page, ordered for use as the section destination preview. const bestSection = pageSections[0]; if (bestSection) { page.bestSection = { @@ -208,5 +205,5 @@ function transformSitePageResult(args: { }; } - return [page]; + return page; } diff --git a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx index 6f77528ee..0f5970804 100644 --- a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx @@ -5,7 +5,7 @@ import { Icon, type IconName } from '@gitbook/icons'; import { SkeletonParagraph } from '../primitives'; import { Tooltip } from '../primitives'; import { Emoji } from '../primitives/Emoji/Emoji'; -import { getPageResultHref } from './getPageResultHref'; +import { getPageResultPresentation } from './getPageResultHref'; import { HighlightQuery } from './HighlightQuery'; import type { MergedPageResult } from './reciprocalRankFusion'; import type { ComputedPageResult } from './search-types'; @@ -28,8 +28,21 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt const { query, item, active, style, ...rest } = props; const language = useLanguage(); - const bestSection = item.type === 'page' ? item.bestSection : undefined; - const href = item.type === 'page' ? getPageResultHref(item) : item.pathname; + const { bestSection, description, href } = (() => { + if (item.type === 'page') { + const presentation = getPageResultPresentation(item); + return { + bestSection: presentation.preview, + description: presentation.description, + href: presentation.href, + }; + } + + return { bestSection: undefined, description: item.description, href: item.pathname }; + })(); + const preview = bestSection + ? [bestSection.title, bestSection.body].filter(Boolean).join(' · ') + : undefined; const emoji = 'emoji' in item ? item.emoji : undefined; const icon = 'icon' in item ? item.icon : undefined; @@ -78,33 +91,32 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
- {bestSection?.body ? ( + {preview ? (

- +

) : null} - {'description' in item && item.description ? ( + {description ? (

- +

) : null} @@ -114,7 +126,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt lines={1} className={tcls( 'absolute inset-0 origin-left', - bestSection?.body || item.description + preview || description ? 'hidden animate-blur-out' : '[[aria-busy=false]_&]:hidden [[aria-busy=false]_&]:animate-blur-out' )} diff --git a/packages/gitbook/src/components/Search/combineRemoteResults.test.ts b/packages/gitbook/src/components/Search/combineRemoteResults.test.ts new file mode 100644 index 000000000..adbfcc23e --- /dev/null +++ b/packages/gitbook/src/components/Search/combineRemoteResults.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'bun:test'; + +import { CURRENT_SITE_SPACE_RRF_WEIGHT, combineRemoteResults } from './combineRemoteResults'; +import type { ComputedPageResult, ComputedRecordResult } from './search-types'; + +function page(title: string, rank: number, score: number, spaceId: string): ComputedPageResult { + return { + type: 'page', + id: `${spaceId}/${title}`, + pageId: title, + spaceId, + title, + description: `Description for ${title}`, + href: `/${title}`, + rank, + score, + }; +} + +function context(title: string, score: number): ComputedRecordResult { + return { + type: 'record', + id: title, + title, + description: undefined, + href: `/context/${title}`, + score, + }; +} + +describe('combineRemoteResults', () => { + it('uses a 1% current-space RRF weight without changing either backend order', () => { + const currentSpace = [ + page('Types of automations', 1, 1, 'runway'), + page('Types of notifications', 2, 100, 'runway'), + context('Runway context', 1_000), + ]; + const otherSpaces = [ + page('Method URL', 1, 10_000, 'cardstream'), + page('Detection overview', 2, 5_000, 'vectra'), + context('Vectra context', 20_000), + ]; + + const results = combineRemoteResults(currentSpace, otherSpaces); + + expect(CURRENT_SITE_SPACE_RRF_WEIGHT).toBe(1.01); + expect(results.map(({ title }) => title)).toEqual([ + 'Types of automations', + 'Method URL', + 'Types of notifications', + 'Detection overview', + 'Runway context', + 'Vectra context', + ]); + expect(results.map(({ score }) => score)).toEqual([1, 10_000, 100, 5_000, 1_000, 20_000]); + expect( + results + .filter( + (result): result is ComputedPageResult => + result.type === 'page' && result.spaceId === 'runway' + ) + .map(({ title }) => title) + ).toEqual(['Types of automations', 'Types of notifications']); + expect( + results + .filter( + (result): result is ComputedPageResult => + result.type === 'page' && result.spaceId !== 'runway' + ) + .map(({ title }) => title) + ).toEqual(['Method URL', 'Detection overview']); + }); +}); diff --git a/packages/gitbook/src/components/Search/combineRemoteResults.ts b/packages/gitbook/src/components/Search/combineRemoteResults.ts new file mode 100644 index 000000000..35822fe34 --- /dev/null +++ b/packages/gitbook/src/components/Search/combineRemoteResults.ts @@ -0,0 +1,45 @@ +import type { OrderedComputedResult } from './search-types'; + +const RRF_K = 60; + +/** At displayed result depths, a 1% weight favors equal current-space ranks, not adjacent ranks. */ +export const CURRENT_SITE_SPACE_RRF_WEIGHT = 1.01; + +type RankedCandidate = { + result: OrderedComputedResult; + fusionScore: number; + inputOrder: number; +}; + +/** Combine disjoint search scopes by rank without comparing their non-comparable BM25 scores. */ +export function combineRemoteResults( + currentSpaceResults: OrderedComputedResult[], + otherSpacesResults: OrderedComputedResult[] +): OrderedComputedResult[] { + const rankedPages: RankedCandidate[] = []; + const context: OrderedComputedResult[] = []; + + addResults(currentSpaceResults, CURRENT_SITE_SPACE_RRF_WEIGHT); + addResults(otherSpacesResults, 1); + + rankedPages.sort( + (left, right) => right.fusionScore - left.fusionScore || left.inputOrder - right.inputOrder + ); + + return [...rankedPages.map(({ result }) => result), ...context]; + + function addResults(results: OrderedComputedResult[], weight: number) { + for (const result of results) { + if (result.type === 'record') { + context.push(result); + continue; + } + + rankedPages.push({ + result, + fusionScore: weight / (RRF_K + result.rank), + inputOrder: rankedPages.length, + }); + } + } +} diff --git a/packages/gitbook/src/components/Search/getPageResultHref.test.ts b/packages/gitbook/src/components/Search/getPageResultHref.test.ts index a2e9f313b..8bca20cdc 100644 --- a/packages/gitbook/src/components/Search/getPageResultHref.test.ts +++ b/packages/gitbook/src/components/Search/getPageResultHref.test.ts @@ -1,35 +1,84 @@ import { describe, expect, it } from 'bun:test'; -import { getPageResultHref } from './getPageResultHref'; +import { getPageResultHref, getPageResultPresentation } from './getPageResultHref'; const pageResult = { - href: '/getting-started', + href: '/operations/analyst-guidance/understanding-vectra-ai-detections', + description: 'Learn how Vectra AI detections help analysts investigate threats.', bestSection: { - href: '/getting-started#installation', - title: 'Installation', - body: 'Install the application.', + href: '/operations/analyst-guidance/understanding-vectra-ai-detections#please-note', + title: 'Please note', + body: 'Individual detections are no longer scored.', score: 1, }, }; describe('getPageResultHref', () => { - it('links page matches to the top of the page', () => { - expect(getPageResultHref({ ...pageResult, resultType: 'page' })).toBe('/getting-started'); + it('links Vectra page matches to the page root without presenting a section destination', () => { + expect(getPageResultPresentation({ ...pageResult, resultType: 'page' })).toEqual({ + href: '/operations/analyst-guidance/understanding-vectra-ai-detections', + description: 'Learn how Vectra AI detections help analysts investigate threats.', + }); }); - it('links section matches to the matching section', () => { - expect(getPageResultHref({ ...pageResult, resultType: 'section' })).toBe( - '/getting-started#installation' - ); + it('links Vectra section matches to the matching section and presents its preview', () => { + expect(getPageResultPresentation({ ...pageResult, resultType: 'section' })).toEqual({ + href: '/operations/analyst-guidance/understanding-vectra-ai-detections#please-note', + preview: { + title: 'Please note', + body: 'Individual detections are no longer scored.', + }, + }); }); it('preserves anchored links when the result type is absent', () => { - expect(getPageResultHref(pageResult)).toBe('/getting-started#installation'); + expect(getPageResultHref(pageResult)).toBe( + '/operations/analyst-guidance/understanding-vectra-ai-detections#please-note' + ); }); it('links to the page when no section preview is available', () => { - expect(getPageResultHref({ href: '/getting-started', resultType: 'section' })).toBe( - '/getting-started' - ); + expect( + getPageResultPresentation({ + href: '/getting-started', + description: 'Start using the product.', + resultType: 'section', + }) + ).toEqual({ + href: '/getting-started', + description: 'Start using the product.', + preview: undefined, + }); + }); + + it('links a section match to its anchor and presents its heading without the page description', () => { + expect( + getPageResultPresentation({ + href: '/getting-started', + description: 'Start using the product.', + resultType: 'section', + bestSection: { + href: '/getting-started#requirements', + title: 'Requirements', + score: 1, + }, + }) + ).toEqual({ + href: '/getting-started#requirements', + description: undefined, + preview: { + title: 'Requirements', + body: undefined, + }, + }); + }); + + it('does not replace an empty page description with arbitrary section content', () => { + expect( + getPageResultPresentation({ ...pageResult, description: '', resultType: 'page' }) + ).toEqual({ + href: '/operations/analyst-guidance/understanding-vectra-ai-detections', + description: '', + }); }); }); diff --git a/packages/gitbook/src/components/Search/getPageResultHref.ts b/packages/gitbook/src/components/Search/getPageResultHref.ts index 7c246692f..abb8dd0f4 100644 --- a/packages/gitbook/src/components/Search/getPageResultHref.ts +++ b/packages/gitbook/src/components/Search/getPageResultHref.ts @@ -1,12 +1,41 @@ import type { ComputedPageResult } from './search-types'; -/** Return the page top for page matches and the preview anchor for section matches. */ -export function getPageResultHref( - result: Pick -): string { +type PageResultPresentation = { + href: string; + description?: string; + preview?: { + title?: string; + body?: string; + }; +}; + +/** Keep the displayed preview consistent with the result's single click destination. */ +export function getPageResultPresentation( + result: Pick +): PageResultPresentation { + const { bestSection } = result; + if (result.resultType === 'page') { - return result.href; + return { + href: result.href, + description: result.description, + }; } - return result.bestSection?.body ? result.bestSection.href : result.href; + return { + href: bestSection?.href ?? result.href, + description: bestSection ? undefined : result.description, + preview: bestSection + ? { + title: bestSection.title, + body: bestSection.body, + } + : undefined, + }; +} + +export function getPageResultHref( + result: Pick +): string { + return getPageResultPresentation(result).href; } diff --git a/packages/gitbook/src/components/Search/reciprocalRankFusion.test.ts b/packages/gitbook/src/components/Search/reciprocalRankFusion.test.ts index 1555b0595..8548ec123 100644 --- a/packages/gitbook/src/components/Search/reciprocalRankFusion.test.ts +++ b/packages/gitbook/src/components/Search/reciprocalRankFusion.test.ts @@ -15,15 +15,17 @@ function localPage(id: string, title = id): LocalPageResult { }; } -function remotePage(id: string, title = id, score = 0): OrderedComputedResult { +function remotePage(id: string, title = id, score = 0, rank = 1): OrderedComputedResult { return { type: 'page', id: `remote-${id}`, pageId: id, spaceId: 'space', title, + description: `Remote description for ${title}`, href: `/${id}`, score, + rank, breadcrumbs: [{ label: 'Remote' }], }; } @@ -101,7 +103,7 @@ describe('reciprocalRankFusion', () => { const results = reciprocalRankFusion( [localPage('remote-1', 'Local title')], [ - remotePage('remote-1', 'Remote title'), + remotePage('remote-1', 'Remote title', 1, 1), remotePage('remote-2'), remotePage('remote-3'), ], @@ -112,7 +114,8 @@ describe('reciprocalRankFusion', () => { expect(pinnedResult.type).toBe('page'); expect(pinnedResult.title).toBe('Remote title'); expect(pinnedResult.pathname).toBe('/remote-1'); - expect(pinnedResult.description).toBe('Local description for Local title'); + expect(pinnedResult.description).toBe('Remote description for Remote title'); + expect(pinnedResult.rank).toBe(1); expect(pinnedResult.breadcrumbs).toEqual([{ label: 'Local', icon: 'book-open' }]); expect(results.map(getResultKey).filter((key) => key === 'page:remote-1')).toHaveLength(1); }); diff --git a/packages/gitbook/src/components/Search/reciprocalRankFusion.ts b/packages/gitbook/src/components/Search/reciprocalRankFusion.ts index 6b6a5e024..38a2faef0 100644 --- a/packages/gitbook/src/components/Search/reciprocalRankFusion.ts +++ b/packages/gitbook/src/components/Search/reciprocalRankFusion.ts @@ -71,15 +71,14 @@ const PINNED_REMOTE_RESULTS_COUNT = 3; /** * A page result that was present in both local and remote lists. - * Local fields (description, icon, emoji, pathname) are carried over as a base, - * and remote fields (href, pageId, spaceId, title) override them. + * Local fields (icon, emoji, pathname) are carried over as a base, and remote fields + * (description, href, pageId, spaceId, title) override them. * Breadcrumbs prefer local (has icon + emoji) and fall back to remote. */ export type MergedPageResult = Omit & { pathname?: string; icon?: string; emoji?: string; - description?: string; breadcrumbs?: LocalPageResult['breadcrumbs'] | ComputedPageResult['breadcrumbs']; }; @@ -140,8 +139,8 @@ function mergePinnedRemoteResult( * * The pinned remote results are excluded from fusion and returned first. Pages * present in both lists are deep-merged: local fields act as the base - * (preserving description, icon, emoji, pathname) and remote fields override - * (providing href, pageId, spaceId, title). Breadcrumbs prefer local (has icon + * (preserving icon, emoji, pathname) and remote fields override (providing the + * authoritative page description, href, pageId, spaceId, and title). Breadcrumbs prefer local (has icon * + emoji) and fall back to remote. In the fused tail, their rank contributions * from both lists are summed. * @@ -195,8 +194,7 @@ export function reciprocalRankFusion( const existing = scoreMap.get(key); if (existing) { // Page found in both lists: sum rank contributions and deep-merge. - // Local is the base (description, icon, emoji, pathname), remote overrides - // (href, pageId, spaceId, breadcrumbs, title). + // Local is the base (icon, emoji, pathname), while remote provides page fields. existing.score += contribution; if (existing.result.type === 'local-page' && result.type === 'page') { existing.result = mergeLocalPageWithRemotePage(existing.result, result); diff --git a/packages/gitbook/src/components/Search/search-types.ts b/packages/gitbook/src/components/Search/search-types.ts index 2ad70e918..b18a0742b 100644 --- a/packages/gitbook/src/components/Search/search-types.ts +++ b/packages/gitbook/src/components/Search/search-types.ts @@ -20,10 +20,14 @@ export type ComputedPageResult = BaseComputedResult & { type: 'page'; pageId: string; spaceId: string; + /** Page-level description for a page-root search destination. */ + description: string; + /** Canonical one-based relevance position assigned by the search backend. */ + rank: number; /** Whether the page matched on its own fields or on one of its sections. */ resultType?: 'page' | 'section'; breadcrumbs?: { icon?: IconName; label: string }[]; - /** The highest-scoring section for this page, used as a body snippet preview. */ + /** The highest-scoring section for this page, used for a section destination preview. */ bestSection?: { href: string; title?: string; diff --git a/packages/gitbook/src/components/Search/useSearchResults.ts b/packages/gitbook/src/components/Search/useSearchResults.ts index 5b70e03ec..3d16dc87a 100644 --- a/packages/gitbook/src/components/Search/useSearchResults.ts +++ b/packages/gitbook/src/components/Search/useSearchResults.ts @@ -4,6 +4,7 @@ import React from 'react'; import { assert } from 'ts-essentials'; import { useTrackEvent } from '../Insights'; +import { combineRemoteResults } from './combineRemoteResults'; import { type RecommendedQuestionResult, createRecommendedQuestionResult, @@ -26,9 +27,6 @@ export type ResultType = export type { LocalPageResult, MergedPageResult }; -// Score multiplier for current site space results when combined with those from other site spaces -const CURRENT_SITE_SPACE_SCORE_MULTIPLIER = 2; - // Small helper extracted for unit testing of scope → local filter mapping // computeFilterSiteSpaceIds is imported from './filter' for testability @@ -423,16 +421,3 @@ async function fetchSearchResults( return response.json() as Promise; } - -function combineRemoteResults( - remoteResultsCurrentSpace: OrderedComputedResult[], - remoteResultsOtherSpaces: OrderedComputedResult[] -): OrderedComputedResult[] { - return [ - ...remoteResultsCurrentSpace.map((result) => ({ - ...result, - score: result.score * CURRENT_SITE_SPACE_SCORE_MULTIPLIER, - })), - ...remoteResultsOtherSpaces, - ].sort((a, b) => b.score - a.score); -}