Use canonical backend order across search spaces

This commit is contained in:
Johan Preynat
2026-08-25 12:59:53 +02:00
parent 9f3f8cacfd
commit dad9c05bc6
5 changed files with 60 additions and 222 deletions
@@ -1,73 +0,0 @@
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']);
});
});
@@ -1,45 +0,0 @@
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,
});
}
}
}
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'bun:test';
import { computeRemoteSearchScope } from './remote-scope';
describe('computeRemoteSearchScope', () => {
it('requests one globally ranked result set for a multi-section default search', () => {
expect(
computeRemoteSearchScope('default', 'snyk-discover', [
'snyk-discover',
'snyk-developer-tools',
])
).toEqual({
mode: 'current',
siteSpaceId: 'snyk-discover',
});
});
});
@@ -0,0 +1,24 @@
import assertNever from 'assert-never';
import type { SearchSiteContentScope } from './search-types';
import type { SearchScope } from './useSearch';
/** Map the UI scope to one backend request so every returned rank is globally comparable. */
export function computeRemoteSearchScope(
scope: SearchScope,
siteSpaceId: string,
siteSpaceIds: string[]
): SearchSiteContentScope {
switch (scope) {
case 'all':
return { mode: 'all' };
case 'default':
return { mode: 'current', siteSpaceId };
case 'extended':
return { mode: 'specific', siteSpaceIds };
case 'current':
return { mode: 'specific', siteSpaceIds: [siteSpaceId] };
default:
assertNever(scope);
}
}
@@ -1,10 +1,8 @@
import { readStreamableValue } from 'ai/rsc';
import assertNever from 'assert-never';
import React from 'react';
import { assert } from 'ts-essentials';
import { useTrackEvent } from '../Insights';
import { combineRemoteResults } from './combineRemoteResults';
import {
type RecommendedQuestionResult,
createRecommendedQuestionResult,
@@ -13,6 +11,7 @@ import {
import { computeFilterSiteSpaceIds } from './filter';
import { useRecentSearchQueries } from './recent-queries';
import { type MergedPageResult, reciprocalRankFusion } from './reciprocalRankFusion';
import { computeRemoteSearchScope } from './remote-scope';
import type { OrderedComputedResult, SearchSiteContentScope } from './search-types';
import { streamRecommendedQuestions } from './server-actions';
import { type LocalPageResult, useLocalSearchResults } from './useLocalSearchResults';
@@ -90,10 +89,9 @@ export function useSearchResults(props: {
const [remoteState, setRemoteState] = React.useState<{
results: OrderedComputedResult[];
otherSpacesResults: OrderedComputedResult[];
fetching: boolean;
error: boolean;
}>({ results: [], otherSpacesResults: [], fetching: false, error: false });
}>({ results: [], fetching: false, error: false });
// Track the current in-flight fetch so it can be aborted imperatively
// when the user navigates away before the request completes.
@@ -111,7 +109,6 @@ export function useSearchResults(props: {
if (!withAI) {
setRemoteState({
results: [],
otherSpacesResults: [],
fetching: false,
error: false,
});
@@ -127,7 +124,6 @@ export function useSearchResults(props: {
// Recommended questions are stored as ResultType[] already
setRemoteState({
results: [],
otherSpacesResults: [],
fetching: false,
error: false,
});
@@ -136,7 +132,6 @@ export function useSearchResults(props: {
setRemoteState({
results: [],
otherSpacesResults: [],
fetching: false,
error: false,
});
@@ -154,7 +149,6 @@ export function useSearchResults(props: {
});
setRemoteState({
results: [],
otherSpacesResults: [],
fetching: false,
error: false,
});
@@ -185,7 +179,6 @@ export function useSearchResults(props: {
// Recommended questions are handled via a separate path below
setRemoteState({
results: [],
otherSpacesResults: [],
fetching: false,
error: false,
});
@@ -200,7 +193,6 @@ export function useSearchResults(props: {
}
setRemoteState({
results: [],
otherSpacesResults: [],
fetching: true,
error: false,
});
@@ -213,81 +205,24 @@ export function useSearchResults(props: {
fetchSearchResults(searchURL, scope, query, abortController.signal, asEmbeddable);
try {
// Each scope resolves to a primary search request and, for the default scope
// on a multi-section site, a secondary request for the other site spaces
const { resultsPromise, otherSpacesResultsPromise } = ((): {
resultsPromise: Promise<OrderedComputedResult[]>;
otherSpacesResultsPromise?: Promise<OrderedComputedResult[]>;
} => {
switch (scope) {
case 'all':
// Search all content on the site
return { resultsPromise: fetchSearch({ mode: 'all' }) };
case 'default':
// Search the current section's variant + matched/default variant for other sections.
// Without sections, the scope resolves to the current site space alone, so a
// second request restricted to the other site spaces would be redundant.
if (!withSections) {
return {
resultsPromise: fetchSearch({ mode: 'current', siteSpaceId }),
};
}
const resultsPromise = fetchSearch(
computeRemoteSearchScope(scope, siteSpaceId, siteSpaceIds)
);
// Split into two parallel requests so the (smaller, faster) current site
// space results can be shown while the other site spaces are still being searched.
return {
resultsPromise: fetchSearch({
mode: 'current',
siteSpaceId,
restrictTo: 'currentSiteSpace',
}),
otherSpacesResultsPromise: fetchSearch({
mode: 'current',
siteSpaceId,
restrictTo: 'otherSiteSpaces',
}),
};
case 'extended':
// Search all variants of the current section
return {
resultsPromise: fetchSearch({ mode: 'specific', siteSpaceIds }),
};
case 'current':
// Search only the current section's current variant
return {
resultsPromise: fetchSearch({
mode: 'specific',
siteSpaceIds: [siteSpaceId],
}),
};
default:
assertNever(scope);
const onResults = (results: OrderedComputedResult[]) => {
if (cancelled) {
return;
}
})();
// Render each result set as soon as its response arrives; a failed
// request reports an error without discarding the other result set.
let tracked = false;
const onResults =
(key: 'results' | 'otherSpacesResults') =>
(results: OrderedComputedResult[]) => {
if (cancelled) {
return;
}
if (!results) {
// Can happen when the route cannot be found and returns the page's html.
setRemoteState((prev) => ({ ...prev, error: true }));
return;
}
if (!results) {
// Can happen when the route cannot be found and returns the page's html.
setRemoteState((prev) => ({ ...prev, error: true }));
return;
}
setRemoteState((prev) => ({ ...prev, [key]: results }));
if (!tracked) {
tracked = true;
trackEvent({ type: 'search_type_query', query });
}
};
setRemoteState((prev) => ({ ...prev, results }));
trackEvent({ type: 'search_type_query', query });
};
const onError = () => {
if (cancelled) {
return;
@@ -295,10 +230,7 @@ export function useSearchResults(props: {
setRemoteState((prev) => ({ ...prev, error: true }));
};
await Promise.all([
resultsPromise.then(onResults('results'), onError),
otherSpacesResultsPromise?.then(onResults('otherSpacesResults'), onError),
]);
await resultsPromise.then(onResults, onError);
if (cancelled) {
return;
@@ -311,7 +243,6 @@ export function useSearchResults(props: {
}
setRemoteState({
results: [],
otherSpacesResults: [],
fetching: false,
error: true,
});
@@ -341,7 +272,6 @@ export function useSearchResults(props: {
suggestions,
searchURL,
asEmbeddable,
withSections,
]);
const abort = React.useCallback(() => {
@@ -368,23 +298,8 @@ export function useSearchResults(props: {
});
}
return reciprocalRankFusion(
localResults,
remoteState.otherSpacesResults.length > 0
? combineRemoteResults(remoteState.results, remoteState.otherSpacesResults)
: remoteState.results,
query
);
}, [
localResults,
remoteState.results,
remoteState.otherSpacesResults,
query,
withAI,
siteSpaceId,
suggestions,
recentQueries,
]);
return reciprocalRankFusion(localResults, remoteState.results, query);
}, [localResults, remoteState.results, query, withAI, siteSpaceId, suggestions, recentQueries]);
return {
results,