feat(nbstore): optimize search performance (#11778)

now we can debug indexeddb performance by devtool

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/g3jz87HxbjOJpXV3FPT7/75cf686f-9bc4-4db3-86ac-12bba83e8d83.png)
This commit is contained in:
EYHN
2025-04-17 13:16:18 +00:00
parent 75df27a145
commit 5e9ad634b7
5 changed files with 116 additions and 33 deletions

View File

@@ -10,6 +10,7 @@ import {
type SearchOptions, type SearchOptions,
type SearchResult, type SearchResult,
} from '../../../storage'; } from '../../../storage';
import { shallowEqual } from '../../../utils/shallow-equal';
import type { DocStorageSchema } from '../schema'; import type { DocStorageSchema } from '../schema';
import { highlighter } from './highlighter'; import { highlighter } from './highlighter';
import { import {
@@ -33,6 +34,8 @@ export type DataStructROTransaction = IDBPTransaction<
'readonly' | 'readwrite' 'readonly' | 'readwrite'
>; >;
let debugMarkCount = 0;
export class DataStruct { export class DataStruct {
database: IDBPDatabase<DocStorageSchema> = null as any; database: IDBPDatabase<DocStorageSchema> = null as any;
invertedIndex = new Map<string, Map<string, InvertedIndex>>(); invertedIndex = new Map<string, Map<string, InvertedIndex>>();
@@ -82,6 +85,7 @@ export class DataStruct {
table: keyof IndexerSchema, table: keyof IndexerSchema,
document: IndexerDocument document: IndexerDocument
) { ) {
using _ = await this.measure(`update`);
const existsNid = await trx const existsNid = await trx
.objectStore('indexerRecords') .objectStore('indexerRecords')
.index('id') .index('id')
@@ -119,6 +123,7 @@ export class DataStruct {
if (!iidx) { if (!iidx) {
continue; continue;
} }
using _ = await this.measure(`insert[${typeInfo.type}]`);
await iidx.insert(trx, nid, values); await iidx.insert(trx, nid, values);
} }
} }
@@ -129,6 +134,7 @@ export class DataStruct {
table: keyof IndexerSchema, table: keyof IndexerSchema,
document: IndexerDocument document: IndexerDocument
) { ) {
using _ = await this.measure(`insert`);
const existsNid = await trx const existsNid = await trx
.objectStore('indexerRecords') .objectStore('indexerRecords')
.index('id') .index('id')
@@ -160,6 +166,7 @@ export class DataStruct {
if (!iidx) { if (!iidx) {
continue; continue;
} }
using _ = await this.measure(`insert[${typeInfo.type}]`);
await iidx.insert(trx, nid, values); await iidx.insert(trx, nid, values);
} }
} }
@@ -183,6 +190,7 @@ export class DataStruct {
table: keyof IndexerSchema, table: keyof IndexerSchema,
id: string id: string
) { ) {
using _ = await this.measure(`delete`);
const nid = await trx const nid = await trx
.objectStore('indexerRecords') .objectStore('indexerRecords')
.index('id') .index('id')
@@ -195,11 +203,12 @@ export class DataStruct {
} }
} }
async deleteByQuery( private async deleteByQuery(
trx: DataStructRWTransaction, trx: DataStructRWTransaction,
table: keyof IndexerSchema, table: keyof IndexerSchema,
query: Query<any> query: Query<any>
) { ) {
using _ = await this.measure(`deleteByQuery`);
const match = await this.queryRaw(trx, table, query); const match = await this.queryRaw(trx, table, query);
for (const nid of match.scores.keys()) { for (const nid of match.scores.keys()) {
@@ -215,6 +224,7 @@ export class DataStruct {
inserts: IndexerDocument<any>[], inserts: IndexerDocument<any>[],
updates: IndexerDocument<any>[] updates: IndexerDocument<any>[]
) { ) {
using _ = await this.measure(`batchWrite`);
for (const query of deleteByQueries) { for (const query of deleteByQueries) {
await this.deleteByQuery(trx, table, query); await this.deleteByQuery(trx, table, query);
} }
@@ -248,8 +258,14 @@ export class DataStruct {
async queryRaw( async queryRaw(
trx: DataStructROTransaction, trx: DataStructROTransaction,
table: keyof IndexerSchema, table: keyof IndexerSchema,
query: Query<any> query: Query<any>,
cache: QueryCache = new QueryCache()
): Promise<Match> { ): Promise<Match> {
const cached = cache.get(query);
if (cached) {
return cached;
}
using _ = await this.measure(`query[${query.type}]`);
if (query.type === 'match') { if (query.type === 'match') {
const iidx = this.invertedIndex.get(table)?.get(query.field as string); const iidx = this.invertedIndex.get(table)?.get(query.field as string);
if (!iidx) { if (!iidx) {
@@ -259,7 +275,7 @@ export class DataStruct {
} else if (query.type === 'boolean') { } else if (query.type === 'boolean') {
const weights = []; const weights = [];
for (const q of query.queries) { for (const q of query.queries) {
weights.push(await this.queryRaw(trx, table, q)); weights.push(await this.queryRaw(trx, table, q, cache));
} }
if (query.occur === 'must') { if (query.occur === 'must') {
return weights.reduce((acc, w) => acc.and(w)); return weights.reduce((acc, w) => acc.and(w));
@@ -272,7 +288,9 @@ export class DataStruct {
} else if (query.type === 'all') { } else if (query.type === 'all') {
return await this.matchAll(trx, table); return await this.matchAll(trx, table);
} else if (query.type === 'boost') { } else if (query.type === 'boost') {
return (await this.queryRaw(trx, table, query.query)).boost(query.boost); return (await this.queryRaw(trx, table, query.query, cache)).boost(
query.boost
);
} else if (query.type === 'exists') { } else if (query.type === 'exists') {
const iidx = this.invertedIndex.get(table)?.get(query.field as string); const iidx = this.invertedIndex.get(table)?.get(query.field as string);
if (!iidx) { if (!iidx) {
@@ -490,4 +508,30 @@ export class DataStruct {
return node; return node;
} }
async measure(name: string) {
const count = debugMarkCount++;
performance.mark(`${name}Start(${count})`);
return {
[Symbol.dispose]: () => {
performance.mark(`${name}End(${count})`);
performance.measure(
`${name}`,
`${name}Start(${count})`,
`${name}End(${count})`
);
},
};
}
}
class QueryCache {
private readonly cache: [Query<any>, Match][] = [];
get(query: Query<any>) {
return this.cache.find(q => shallowEqual(q[0], query))?.[1];
}
set(query: Query<any>, match: Match) {
this.cache.push([query, match]);
}
} }

View File

@@ -0,0 +1,34 @@
// credit: https://github.com/facebook/fbjs/blob/main/packages/fbjs/src/core/shallowEqual.js
export function shallowEqual(objA: any, objB: any) {
if (Object.is(objA, objB)) {
return true;
}
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) {
return false;
}
// Test for A's keys different from B.
for (const key of keysA) {
if (
!Object.prototype.hasOwnProperty.call(objB, key) ||
!Object.is(objA[key], objB[key])
) {
return false;
}
}
return true;
}

View File

@@ -52,7 +52,9 @@ export class DocsSearchService extends Service {
occur: 'should', occur: 'should',
queries: [ queries: [
{ {
type: 'all', type: 'match',
field: 'content',
match: query,
}, },
{ {
type: 'boost', type: 'boost',

View File

@@ -6,7 +6,7 @@ import {
onStart, onStart,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { truncate } from 'lodash-es'; import { truncate } from 'lodash-es';
import { EMPTY, map, mergeMap, of, switchMap } from 'rxjs'; import { EMPTY, map, mergeMap, of, switchMap, throttleTime } from 'rxjs';
import type { DocRecord, DocsService } from '../../doc'; import type { DocRecord, DocsService } from '../../doc';
import type { DocDisplayMetaService } from '../../doc-display-meta'; import type { DocDisplayMetaService } from '../../doc-display-meta';
@@ -50,6 +50,10 @@ export class DocsQuickSearchSession
items$ = new LiveData<QuickSearchItem<'docs', DocsPayload>[]>([]); items$ = new LiveData<QuickSearchItem<'docs', DocsPayload>[]>([]);
query = effect( query = effect(
throttleTime<string>(1000, undefined, {
leading: false,
trailing: true,
}),
switchMap((query: string) => { switchMap((query: string) => {
let out; let out;
if (!query) { if (!query) {

View File

@@ -143,43 +143,42 @@ export class SearchMenuService extends Service {
// only search docs by title, excluding blocks // only search docs by title, excluding blocks
private searchDocs$(query: string) { private searchDocs$(query: string) {
return this.docsSearch.indexer return this.docsSearch.indexer
.aggregate$( .search$(
'doc', 'doc',
{ {
type: 'boolean', type: 'match',
occur: 'must', field: 'title',
queries: [ match: query,
},
{
fields: ['docId', 'title'],
pagination: {
limit: 1,
},
highlights: [
{ {
type: 'match',
field: 'title', field: 'title',
match: query, before: `<span style="color: ${cssVarV2('text/emphasis')}">`,
end: '</span>',
}, },
], ],
},
'docId',
{
hits: {
fields: ['docId', 'title'],
pagination: {
limit: 1,
},
highlights: [
{
field: 'title',
before: `<span style="color: ${cssVarV2('text/emphasis')}">`,
end: '</span>',
},
],
},
} }
) )
.pipe( .pipe(
map(({ buckets }) => map(({ nodes }) =>
buckets.map(bucket => { nodes.map(node => {
const id =
typeof node.fields.docId === 'string'
? node.fields.docId
: node.fields.docId[0];
const title =
typeof node.fields.title === 'string'
? node.fields.title
: node.fields.title[0];
return { return {
id: bucket.key, id,
title: bucket.hits.nodes[0].fields.title, title,
highlights: bucket.hits.nodes[0].highlights.title[0], highlights: node.highlights.title[0],
}; };
}) })
) )