mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 05:48:57 +00:00
Split the default-scope site search into two parallel API requests (#4395)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Split the default-scope site search into two parallel API requests — one restricted to the current site space and one for the other site spaces — rendering each result set as soon as its response arrives. All results are ranked together by score, with the current site space scores boosted.
|
||||
@@ -14,7 +14,7 @@ function localPage(id: string, title = id): LocalPageResult {
|
||||
};
|
||||
}
|
||||
|
||||
function remotePage(id: string, title = id): OrderedComputedResult {
|
||||
function remotePage(id: string, title = id, score = 0): OrderedComputedResult {
|
||||
return {
|
||||
type: 'page',
|
||||
id: `remote-${id}`,
|
||||
@@ -22,7 +22,7 @@ function remotePage(id: string, title = id): OrderedComputedResult {
|
||||
spaceId: 'space',
|
||||
title,
|
||||
href: `/${id}`,
|
||||
score: 0,
|
||||
score,
|
||||
breadcrumbs: [{ label: 'Remote' }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ function mergePinnedRemoteResult(
|
||||
|
||||
/**
|
||||
* Merge local (FlexSearch) and remote (API) search results using
|
||||
* Reciprocal Rank Fusion (RRF), while preserving the API order for the first
|
||||
* Reciprocal Rank Fusion (RRF), while preserving the order of the first
|
||||
* three remote results.
|
||||
*
|
||||
* RRF formula: score(d) = Σ_i 1 / (k + rank_i(d))
|
||||
|
||||
@@ -37,7 +37,12 @@ export type ComputedRecordResult = BaseComputedResult & {
|
||||
|
||||
export type SearchSiteContentScope =
|
||||
| { mode: 'all' }
|
||||
| { mode: 'current'; siteSpaceId: string }
|
||||
| {
|
||||
mode: 'current';
|
||||
siteSpaceId: string;
|
||||
/** Restrict the search to the current site space alone, or to the other site spaces in the scope. */
|
||||
restrictTo?: 'currentSiteSpace' | 'otherSiteSpaces';
|
||||
}
|
||||
| { mode: 'specific'; siteSpaceIds: string[] };
|
||||
|
||||
export interface SearchSiteContentRequest {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
createRecommendedQuestionResult,
|
||||
getEmptySearchResults,
|
||||
} from './empty-search-results';
|
||||
import type { OrderedComputedResult } from './search-types';
|
||||
import type { OrderedComputedResult, SearchSiteContentScope } from './search-types';
|
||||
import { streamRecommendedQuestions } from './server-actions';
|
||||
|
||||
import { useAI } from '@/components/AI';
|
||||
@@ -28,6 +28,9 @@ 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
|
||||
|
||||
@@ -87,9 +90,10 @@ export function useSearchResults(props: {
|
||||
|
||||
const [remoteState, setRemoteState] = React.useState<{
|
||||
results: OrderedComputedResult[];
|
||||
otherSpacesResults: OrderedComputedResult[];
|
||||
fetching: boolean;
|
||||
error: boolean;
|
||||
}>({ results: [], fetching: false, error: false });
|
||||
}>({ results: [], otherSpacesResults: [], 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.
|
||||
@@ -105,7 +109,12 @@ export function useSearchResults(props: {
|
||||
}
|
||||
if (!query) {
|
||||
if (!withAI) {
|
||||
setRemoteState({ results: [], fetching: false, error: false });
|
||||
setRemoteState({
|
||||
results: [],
|
||||
otherSpacesResults: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -116,11 +125,21 @@ export function useSearchResults(props: {
|
||||
`Cached recommended questions should be set for site-space ${siteSpaceId}`
|
||||
);
|
||||
// Recommended questions are stored as ResultType[] already
|
||||
setRemoteState({ results: [], fetching: false, error: false });
|
||||
setRemoteState({
|
||||
results: [],
|
||||
otherSpacesResults: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setRemoteState({ results: [], fetching: false, error: false });
|
||||
setRemoteState({
|
||||
results: [],
|
||||
otherSpacesResults: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
@@ -133,7 +152,12 @@ export function useSearchResults(props: {
|
||||
suggestions.forEach((question) => {
|
||||
questions.add(question);
|
||||
});
|
||||
setRemoteState({ results: [], fetching: false, error: false });
|
||||
setRemoteState({
|
||||
results: [],
|
||||
otherSpacesResults: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -159,7 +183,12 @@ export function useSearchResults(props: {
|
||||
|
||||
if (!cancelled) {
|
||||
// Recommended questions are handled via a separate path below
|
||||
setRemoteState({ results: [], fetching: false, error: false });
|
||||
setRemoteState({
|
||||
results: [],
|
||||
otherSpacesResults: [],
|
||||
fetching: false,
|
||||
error: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
@@ -171,67 +200,121 @@ export function useSearchResults(props: {
|
||||
}
|
||||
setRemoteState({
|
||||
results: [],
|
||||
otherSpacesResults: [],
|
||||
fetching: true,
|
||||
error: false,
|
||||
});
|
||||
let cancelled = false;
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
const results = await (() => {
|
||||
const fetchSearch = (
|
||||
scope: Parameters<typeof fetchSearchResults>[1]
|
||||
): Promise<OrderedComputedResult[]> =>
|
||||
fetchSearchResults(
|
||||
searchURL,
|
||||
scope,
|
||||
query,
|
||||
abortController.signal,
|
||||
asEmbeddable
|
||||
);
|
||||
const fetchSearch = (
|
||||
scope: Parameters<typeof fetchSearchResults>[1]
|
||||
): Promise<OrderedComputedResult[]> =>
|
||||
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 fetchSearch({ mode: 'all' });
|
||||
return { resultsPromise: fetchSearch({ mode: 'all' }) };
|
||||
case 'default':
|
||||
// Search the current section's variant + matched/default variant for other sections
|
||||
return fetchSearch({ mode: 'current', siteSpaceId });
|
||||
// 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 }),
|
||||
};
|
||||
}
|
||||
|
||||
// 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 fetchSearch({ mode: 'specific', siteSpaceIds });
|
||||
return {
|
||||
resultsPromise: fetchSearch({ mode: 'specific', siteSpaceIds }),
|
||||
};
|
||||
case 'current':
|
||||
// Search only the current section's current variant
|
||||
return fetchSearch({ mode: 'specific', siteSpaceIds: [siteSpaceId] });
|
||||
return {
|
||||
resultsPromise: fetchSearch({
|
||||
mode: 'specific',
|
||||
siteSpaceIds: [siteSpaceId],
|
||||
}),
|
||||
};
|
||||
default:
|
||||
assertNever(scope);
|
||||
}
|
||||
})();
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
setRemoteState((prev) => ({ ...prev, [key]: results }));
|
||||
|
||||
if (!tracked) {
|
||||
tracked = true;
|
||||
trackEvent({ type: 'search_type_query', query });
|
||||
}
|
||||
};
|
||||
const onError = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setRemoteState((prev) => ({ ...prev, error: true }));
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
resultsPromise.then(onResults('results'), onError),
|
||||
otherSpacesResultsPromise?.then(onResults('otherSpacesResults'), onError),
|
||||
]);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!results) {
|
||||
// One time when this one returns undefined is when it cannot find the server action and returns the html from the page.
|
||||
// In that case, we want to avoid being stuck in a loading state, but it is an error.
|
||||
// We could potentially try to force reload the page here, but i'm not 100% sure it would be a better experience.
|
||||
setRemoteState({ results: [], fetching: false, error: true });
|
||||
return;
|
||||
}
|
||||
|
||||
setRemoteState({ results, fetching: false, error: false });
|
||||
|
||||
trackEvent({
|
||||
type: 'search_type_query',
|
||||
query,
|
||||
});
|
||||
setRemoteState((prev) => ({ ...prev, fetching: false }));
|
||||
} catch {
|
||||
// If there is an error, we need to catch it to avoid infinite loading state.
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setRemoteState({ results: [], fetching: false, error: true });
|
||||
setRemoteState({
|
||||
results: [],
|
||||
otherSpacesResults: [],
|
||||
fetching: false,
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
}, 200);
|
||||
|
||||
@@ -258,6 +341,7 @@ export function useSearchResults(props: {
|
||||
suggestions,
|
||||
searchURL,
|
||||
asEmbeddable,
|
||||
withSections,
|
||||
]);
|
||||
|
||||
const abort = React.useCallback(() => {
|
||||
@@ -284,10 +368,23 @@ export function useSearchResults(props: {
|
||||
});
|
||||
}
|
||||
|
||||
const merged = reciprocalRankFusion(localResults, remoteState.results, query);
|
||||
|
||||
return merged;
|
||||
}, [localResults, remoteState.results, query, withAI, siteSpaceId, suggestions, recentQueries]);
|
||||
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 {
|
||||
results,
|
||||
@@ -302,10 +399,7 @@ export function useSearchResults(props: {
|
||||
*/
|
||||
async function fetchSearchResults(
|
||||
searchURL: string,
|
||||
scope:
|
||||
| { mode: 'all' }
|
||||
| { mode: 'current'; siteSpaceId: string }
|
||||
| { mode: 'specific'; siteSpaceIds: string[] },
|
||||
scope: SearchSiteContentScope,
|
||||
query: string,
|
||||
signal?: AbortSignal,
|
||||
asEmbeddable?: boolean
|
||||
@@ -327,3 +421,16 @@ 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);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
GitBookAPI,
|
||||
type HttpResponse,
|
||||
type RenderIntegrationUI,
|
||||
type SiteSearchScope,
|
||||
} from '@gitbook/api';
|
||||
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
|
||||
import { parse as parseCacheControl } from '@tusbar/cache-control';
|
||||
@@ -785,7 +786,17 @@ const searchSiteContent = cache(
|
||||
siteId,
|
||||
{
|
||||
query,
|
||||
...scope,
|
||||
...(scope.mode === 'current' && scope.restrictTo
|
||||
? {
|
||||
// `restrictTo` only exists in the newer `scope` request shape,
|
||||
// and the published @gitbook/api types don't include it yet.
|
||||
scope: {
|
||||
mode: 'default',
|
||||
currentSiteSpace: scope.siteSpaceId,
|
||||
restrictTo: scope.restrictTo,
|
||||
} as SiteSearchScope,
|
||||
}
|
||||
: scope),
|
||||
},
|
||||
{},
|
||||
{
|
||||
|
||||
@@ -178,7 +178,12 @@ export interface GitBookDataFetcher {
|
||||
query: string;
|
||||
scope:
|
||||
| { mode: 'all' }
|
||||
| { mode: 'current'; siteSpaceId: string }
|
||||
| {
|
||||
mode: 'current';
|
||||
siteSpaceId: string;
|
||||
/** Restrict the search to the current site space alone, or to the other site spaces in the scope. */
|
||||
restrictTo?: 'currentSiteSpace' | 'otherSiteSpaces';
|
||||
}
|
||||
| { mode: 'specific'; siteSpaceIds: string[] };
|
||||
/** Cache bust to ensure the search results are fresh when the space is updated. */
|
||||
cacheBust?: string;
|
||||
|
||||
Reference in New Issue
Block a user