Preserve backend ranking in published search (#4537)

This commit is contained in:
Johan Preynat
2026-08-25 12:54:24 +02:00
committed by GitHub
parent b772f74c2b
commit 5da854f09c
13 changed files with 376 additions and 91 deletions
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Preserve canonical backend ranking and present page or section context that matches each published search destination.
@@ -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]);
});
});
@@ -0,0 +1,34 @@
type RankedPageResult<TResult> = {
rank: number;
result: TResult;
};
export type SearchResultGroup<TResult> =
| { type: 'pages'; results: RankedPageResult<TResult>[] }
| { type: 'context'; results: TResult[] };
/** Reconstruct the backend's global page order after it grouped results by space. */
export function orderSearchResultGroups<TResult>(groups: SearchResultGroup<TResult>[]): TResult[] {
const pages: (RankedPageResult<TResult> & { 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];
}
@@ -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;
}
@@ -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
<div
className={tcls(
'relative h-5 w-full transition-[height] duration-300',
item.type === 'local-page' && !bestSection?.body && !item.description
item.type === 'local-page' && !preview && !description
? '[[aria-busy=false]_&]:h-0'
: ''
: !preview && !description
? 'h-0'
: ''
)}
style={{ transitionDelay: style?.animationDelay }}
>
{bestSection?.body ? (
{preview ? (
<p
className="animate-blur-in absolute inset-0 line-clamp-1 origin-left text-sm"
style={{ animationDelay: style?.animationDelay }}
>
<HighlightQuery
query={query}
text={`${bestSection.title ? `${bestSection.title} · ` : ''}${bestSection.body}`}
/>
<HighlightQuery query={query} text={preview} />
</p>
) : null}
{'description' in item && item.description ? (
{description ? (
<p
className={tcls(
'absolute inset-0 line-clamp-1 origin-left text-sm',
bestSection?.body ? 'hidden animate-blur-out' : ''
preview ? 'hidden animate-blur-out' : ''
)}
style={{ animationDelay: style?.animationDelay }}
>
<HighlightQuery query={query} text={item.description} />
<HighlightQuery query={query} text={description} />
</p>
) : 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'
)}
@@ -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']);
});
});
@@ -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,
});
}
}
}
@@ -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: '',
});
});
});
@@ -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<ComputedPageResult, 'bestSection' | 'href' | 'resultType'>
): 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<ComputedPageResult, 'bestSection' | 'description' | 'href' | 'resultType'>
): 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<ComputedPageResult, 'bestSection' | 'description' | 'href' | 'resultType'>
): string {
return getPageResultPresentation(result).href;
}
@@ -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);
});
@@ -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<ComputedPageResult, 'breadcrumbs'> & {
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);
@@ -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;
@@ -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<OrderedComputedResult[]>;
}
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);
}