optimize search query and ts_headline usage

This commit is contained in:
Philipinho
2026-01-30 00:18:30 +00:00
parent 60501de992
commit b87bef0016
2 changed files with 81 additions and 75 deletions
+84 -78
View File
@@ -30,104 +30,110 @@ export class SearchService {
const { query } = searchParams; const { query } = searchParams;
if (query.length < 1) { if (query.length < 1) {
return; return [];
} }
const searchQuery = tsquery(query.trim() + '*'); const searchQuery = tsquery(query.trim() + '*');
const limit = searchParams.limit || 25;
const offset = searchParams.offset || 0;
const includeSpace = !searchParams.shareId;
let queryResults = this.db // Handle share search - resolve page IDs first
.selectFrom('pages') let sharePageIds: string[] | null = null;
.select([ if (searchParams.shareId && !searchParams.spaceId && !opts.userId) {
'id', const share = await this.shareRepo.findById(searchParams.shareId);
'slugId', if (!share || share.workspaceId !== opts.workspaceId) {
'title', return [];
'icon',
'parentPageId',
'creatorId',
'createdAt',
'updatedAt',
sql<number>`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as(
'rank',
),
sql<string>`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as(
'highlight',
),
])
.where(
'tsv',
'@@',
sql<string>`to_tsquery('english', f_unaccent(${searchQuery}))`,
)
.$if(Boolean(searchParams.creatorId), (qb) =>
qb.where('creatorId', '=', searchParams.creatorId),
)
.where('deletedAt', 'is', null)
.orderBy('rank', 'desc')
.limit(searchParams.limit | 25)
.offset(searchParams.offset || 0);
if (!searchParams.shareId) {
queryResults = queryResults.select((eb) => this.pageRepo.withSpace(eb));
} }
if (share.includeSubPages) {
const pageList = await this.pageRepo.getPageAndDescendants(
share.pageId,
{ includeContent: false },
);
sharePageIds = pageList.map((page) => page.id);
} else {
sharePageIds = [share.pageId];
}
if (sharePageIds.length === 0) {
return [];
}
} else if (!searchParams.spaceId && !opts.userId) {
return [];
}
// CTE to get top N page IDs by rank (without expensive ts_headline)
// Join back to compute ts_headline only for those N rows
const tsQuery = sql<string>`to_tsquery('english', f_unaccent(${searchQuery}))`;
const queryResults = await this.db
.with('ranked_pages', (db) => {
let rankQuery = db
.selectFrom('pages')
.select(['id', sql<number>`ts_rank(tsv, ${tsQuery})`.as('rank')])
.where('tsv', '@@', tsQuery)
.where('deletedAt', 'is', null)
.$if(Boolean(searchParams.creatorId), (qb) =>
qb.where('creatorId', '=', searchParams.creatorId),
);
if (searchParams.spaceId) { if (searchParams.spaceId) {
// search by spaceId rankQuery = rankQuery.where('spaceId', '=', searchParams.spaceId);
queryResults = queryResults.where('spaceId', '=', searchParams.spaceId); } else if (opts.userId) {
} else if (opts.userId && !searchParams.spaceId) { rankQuery = rankQuery
// only search spaces the user is a member of
queryResults = queryResults
.where( .where(
'spaceId', 'spaceId',
'in', 'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(opts.userId), this.spaceMemberRepo.getUserSpaceIdsQuery(opts.userId),
) )
.where('workspaceId', '=', opts.workspaceId); .where('workspaceId', '=', opts.workspaceId);
} else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) { } else if (sharePageIds) {
// search in shares rankQuery = rankQuery
const shareId = searchParams.shareId; .where('id', 'in', sharePageIds)
const share = await this.shareRepo.findById(shareId);
if (!share || share.workspaceId !== opts.workspaceId) {
return [];
}
const pageIdsToSearch = [];
if (share.includeSubPages) {
const pageList = await this.pageRepo.getPageAndDescendants(
share.pageId,
{
includeContent: false,
},
);
pageIdsToSearch.push(...pageList.map((page) => page.id));
} else {
pageIdsToSearch.push(share.pageId);
}
if (pageIdsToSearch.length > 0) {
queryResults = queryResults
.where('id', 'in', pageIdsToSearch)
.where('workspaceId', '=', opts.workspaceId); .where('workspaceId', '=', opts.workspaceId);
} else {
return [];
}
} else {
return [];
} }
//@ts-ignore return rankQuery.orderBy('rank', 'desc').limit(limit).offset(offset);
queryResults = await queryResults.execute(); })
.selectFrom('ranked_pages')
.innerJoin('pages', 'pages.id', 'ranked_pages.id')
.select([
'pages.id',
'pages.slugId',
'pages.title',
'pages.icon',
'pages.parentPageId',
'pages.creatorId',
'pages.createdAt',
'pages.updatedAt',
'ranked_pages.rank',
sql<string>`ts_headline('english', pages.text_content, ${tsQuery}, 'MinWords=9, MaxWords=10, MaxFragments=3')`.as(
'highlight',
),
])
.$if(includeSpace, (qb) =>
qb.innerJoin('spaces', 'spaces.id', 'pages.spaceId').select(
sql<{
id: string;
name: string;
slug: string;
}>`jsonb_build_object('id', spaces.id, 'name', spaces.name, 'slug', spaces.slug)`.as(
'space',
),
),
)
.orderBy('ranked_pages.rank', 'desc')
.execute();
//@ts-ignore return queryResults.map((result) => {
const searchResults = queryResults.map((result: SearchResponseDto) => { const mapped = result as unknown as SearchResponseDto;
if (result.highlight) { if (mapped.highlight) {
result.highlight = result.highlight mapped.highlight = mapped.highlight
.replace(/\r\n|\r|\n/g, ' ') .replace(/\r\n|\r|\n/g, ' ')
.replace(/\s+/g, ' '); .replace(/\s+/g, ' ');
} }
return result; return mapped;
}); });
return searchResults;
} }
async searchSuggestions( async searchSuggestions(