feat(server): search docs by keywork from indexer (#12863)

#### PR Dependency Tree


* **PR #12867**
  * **PR #12863** 👈
    * **PR #12837**
    * **PR #12866**

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
fengmk2
2025-06-20 17:48:30 +08:00
committed by GitHub
parent bebe4349a9
commit 62d74de810
7 changed files with 315 additions and 0 deletions
@@ -33,6 +33,7 @@ import {
} from './tables';
import {
AggregateInput,
SearchDoc,
SearchHighlight,
SearchInput,
SearchQuery,
@@ -433,6 +434,155 @@ export class IndexerService {
return blobNameMap;
}
async searchDocsByKeyword(
workspaceId: string,
keyword: string,
options?: {
limit?: number;
}
): Promise<SearchDoc[]> {
const limit = options?.limit ?? 20;
const result = await this.aggregate({
table: SearchTable.block,
field: 'docId',
query: {
type: SearchQueryType.boolean,
occur: SearchQueryOccur.must,
queries: [
{
type: SearchQueryType.match,
field: 'workspaceId',
match: workspaceId,
},
{
type: SearchQueryType.boolean,
occur: SearchQueryOccur.must,
queries: [
{
type: SearchQueryType.match,
field: 'content',
match: keyword,
},
{
type: SearchQueryType.boolean,
occur: SearchQueryOccur.should,
queries: [
{
type: SearchQueryType.match,
field: 'content',
match: keyword,
},
{
type: SearchQueryType.boost,
boost: 1.5,
query: {
type: SearchQueryType.match,
field: 'flavour',
match: 'affine:page',
},
},
],
},
],
},
],
},
options: {
hits: {
fields: [
'blockId',
'flavour',
'content',
'createdAt',
'updatedAt',
'createdByUserId',
'updatedByUserId',
],
highlights: [
{
field: 'content',
before: '<b>',
end: '</b>',
},
],
pagination: {
limit: 2,
},
},
pagination: {
limit,
},
},
});
const docs: SearchDoc[] = [];
const missingTitles: { workspaceId: string; docId: string }[] = [];
const userIds: { userId: string }[] = [];
for (const bucket of result.buckets) {
const docId = bucket.key;
const blockId = bucket.hits.nodes[0].fields.blockId[0] as string;
const flavour = bucket.hits.nodes[0].fields.flavour[0] as string;
const content = bucket.hits.nodes[0].fields.content[0] as string;
const createdAt = bucket.hits.nodes[0].fields.createdAt[0] as Date;
const updatedAt = bucket.hits.nodes[0].fields.updatedAt[0] as Date;
const createdByUserId = bucket.hits.nodes[0].fields
.createdByUserId[0] as string;
const updatedByUserId = bucket.hits.nodes[0].fields
.updatedByUserId[0] as string;
const highlight = bucket.hits.nodes[0].highlights?.content?.[0] as string;
let title = '';
// hit title block
if (flavour === 'affine:page') {
title = content;
} else {
// hit content block, missing title
missingTitles.push({ workspaceId, docId });
}
docs.push({
docId,
blockId,
title,
highlight,
createdAt,
updatedAt,
createdByUserId,
updatedByUserId,
});
userIds.push({ userId: createdByUserId }, { userId: updatedByUserId });
}
if (missingTitles.length > 0) {
const metas = await this.models.doc.findMetas(missingTitles, {
select: {
title: true,
},
});
const titleMap = new Map<string, string>();
for (const meta of metas) {
if (meta?.title) {
titleMap.set(meta.docId, meta.title);
}
}
for (const doc of docs) {
if (!doc.title) {
doc.title = titleMap.get(doc.docId) ?? '';
}
}
}
const userMap = await this.models.user.getPublicUsersMap(userIds);
for (const doc of docs) {
doc.createdByUser = userMap.get(doc.createdByUserId);
doc.updatedByUser = userMap.get(doc.updatedByUserId);
}
return docs;
}
#formatSearchNodes(nodes: SearchNode[]) {
return nodes.map(node => ({
...node,