Compare commits

...

1 Commits

Author SHA1 Message Date
Philipinho b87bef0016 optimize search query and ts_headline usage 2026-01-30 00:18:30 +00:00
2 changed files with 81 additions and 75 deletions
+80 -74
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',
'title',
'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 (searchParams.spaceId) {
// search by spaceId
queryResults = queryResults.where('spaceId', '=', searchParams.spaceId);
} else if (opts.userId && !searchParams.spaceId) {
// only search spaces the user is a member of
queryResults = queryResults
.where(
'spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(opts.userId),
)
.where('workspaceId', '=', opts.workspaceId);
} else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) {
// search in shares
const shareId = searchParams.shareId;
const share = await this.shareRepo.findById(shareId);
if (!share || share.workspaceId !== opts.workspaceId) { if (!share || share.workspaceId !== opts.workspaceId) {
return []; return [];
} }
const pageIdsToSearch = [];
if (share.includeSubPages) { if (share.includeSubPages) {
const pageList = await this.pageRepo.getPageAndDescendants( const pageList = await this.pageRepo.getPageAndDescendants(
share.pageId, share.pageId,
{ { includeContent: false },
includeContent: false,
},
); );
sharePageIds = pageList.map((page) => page.id);
pageIdsToSearch.push(...pageList.map((page) => page.id));
} else { } else {
pageIdsToSearch.push(share.pageId); sharePageIds = [share.pageId];
} }
if (pageIdsToSearch.length > 0) { if (sharePageIds.length === 0) {
queryResults = queryResults
.where('id', 'in', pageIdsToSearch)
.where('workspaceId', '=', opts.workspaceId);
} else {
return []; return [];
} }
} else { } else if (!searchParams.spaceId && !opts.userId) {
return []; return [];
} }
//@ts-ignore // CTE to get top N page IDs by rank (without expensive ts_headline)
queryResults = await queryResults.execute(); // Join back to compute ts_headline only for those N rows
const tsQuery = sql<string>`to_tsquery('english', f_unaccent(${searchQuery}))`;
//@ts-ignore const queryResults = await this.db
const searchResults = queryResults.map((result: SearchResponseDto) => { .with('ranked_pages', (db) => {
if (result.highlight) { let rankQuery = db
result.highlight = result.highlight .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) {
rankQuery = rankQuery.where('spaceId', '=', searchParams.spaceId);
} else if (opts.userId) {
rankQuery = rankQuery
.where(
'spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(opts.userId),
)
.where('workspaceId', '=', opts.workspaceId);
} else if (sharePageIds) {
rankQuery = rankQuery
.where('id', 'in', sharePageIds)
.where('workspaceId', '=', opts.workspaceId);
}
return rankQuery.orderBy('rank', 'desc').limit(limit).offset(offset);
})
.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();
return queryResults.map((result) => {
const mapped = result as unknown as SearchResponseDto;
if (mapped.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(