diff --git a/packages/gitbook/src/components/Search/combineRemoteResults.test.ts b/packages/gitbook/src/components/Search/combineRemoteResults.test.ts deleted file mode 100644 index adbfcc23e..000000000 --- a/packages/gitbook/src/components/Search/combineRemoteResults.test.ts +++ /dev/null @@ -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']); - }); -}); diff --git a/packages/gitbook/src/components/Search/combineRemoteResults.ts b/packages/gitbook/src/components/Search/combineRemoteResults.ts deleted file mode 100644 index 35822fe34..000000000 --- a/packages/gitbook/src/components/Search/combineRemoteResults.ts +++ /dev/null @@ -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, - }); - } - } -} diff --git a/packages/gitbook/src/components/Search/remote-scope.test.ts b/packages/gitbook/src/components/Search/remote-scope.test.ts new file mode 100644 index 000000000..18db139ec --- /dev/null +++ b/packages/gitbook/src/components/Search/remote-scope.test.ts @@ -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', + }); + }); +}); diff --git a/packages/gitbook/src/components/Search/remote-scope.ts b/packages/gitbook/src/components/Search/remote-scope.ts new file mode 100644 index 000000000..54ec6bd91 --- /dev/null +++ b/packages/gitbook/src/components/Search/remote-scope.ts @@ -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); + } +} diff --git a/packages/gitbook/src/components/Search/useSearchResults.ts b/packages/gitbook/src/components/Search/useSearchResults.ts index 3d16dc87a..3ff33926d 100644 --- a/packages/gitbook/src/components/Search/useSearchResults.ts +++ b/packages/gitbook/src/components/Search/useSearchResults.ts @@ -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; - otherSpacesResultsPromise?: Promise; - } => { - 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,