mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-28 03:12:19 +08:00
feat(nbstore): optimize search performance (#11778)
now we can debug indexeddb performance by devtool 
This commit is contained in:
@@ -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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
packages/common/nbstore/src/utils/shallow-equal.ts
Normal file
34
packages/common/nbstore/src/utils/shallow-equal.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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],
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user