Merge pull request #3106 from joenunezb/optimize/improve-search

perf(search): improve fuzzy character search performance by ~13x (4.5s → 350ms)
This commit is contained in:
Cohee 2024-11-30 17:50:40 +02:00 committed by GitHub
commit c3c16ea0d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 136 additions and 93 deletions

View File

@ -1621,7 +1621,7 @@ export function getEntitiesList({ doFilter = false, doSort = true } = {}) {
subEntities = filterByTagState(entities, { subForEntity: entity }); subEntities = filterByTagState(entities, { subForEntity: entity });
if (doFilter) { if (doFilter) {
// sub entities filter "hacked" because folder filter should not be applied there, so even in "only folders" mode characters show up // sub entities filter "hacked" because folder filter should not be applied there, so even in "only folders" mode characters show up
subEntities = entitiesFilter.applyFilters(subEntities, { clearScoreCache: false, tempOverrides: { [FILTER_TYPES.FOLDER]: FILTER_STATES.UNDEFINED } }); subEntities = entitiesFilter.applyFilters(subEntities, { clearScoreCache: false, tempOverrides: { [FILTER_TYPES.FOLDER]: FILTER_STATES.UNDEFINED }, clearFuzzySearchCaches: false });
} }
if (doSort) { if (doSort) {
sortEntitiesList(subEntities); sortEntitiesList(subEntities);
@ -1634,11 +1634,11 @@ export function getEntitiesList({ doFilter = false, doSort = true } = {}) {
// Second run filters, hiding whatever should be filtered later // Second run filters, hiding whatever should be filtered later
if (doFilter) { if (doFilter) {
const beforeFinalEntities = filterByTagState(entities, { globalDisplayFilters: true }); const beforeFinalEntities = filterByTagState(entities, { globalDisplayFilters: true });
entities = entitiesFilter.applyFilters(beforeFinalEntities); entities = entitiesFilter.applyFilters(beforeFinalEntities, { clearFuzzySearchCaches: false });
// Magic for folder filter. If that one is enabled, and no folders are display anymore, we remove that filter to actually show the characters. // Magic for folder filter. If that one is enabled, and no folders are display anymore, we remove that filter to actually show the characters.
if (isFilterState(entitiesFilter.getFilterData(FILTER_TYPES.FOLDER), FILTER_STATES.SELECTED) && entities.filter(x => x.type == 'tag').length == 0) { if (isFilterState(entitiesFilter.getFilterData(FILTER_TYPES.FOLDER), FILTER_STATES.SELECTED) && entities.filter(x => x.type == 'tag').length == 0) {
entities = entitiesFilter.applyFilters(beforeFinalEntities, { tempOverrides: { [FILTER_TYPES.FOLDER]: FILTER_STATES.UNDEFINED } }); entities = entitiesFilter.applyFilters(beforeFinalEntities, { tempOverrides: { [FILTER_TYPES.FOLDER]: FILTER_STATES.UNDEFINED }, clearFuzzySearchCaches: false });
} }
} }
@ -1654,6 +1654,7 @@ export function getEntitiesList({ doFilter = false, doSort = true } = {}) {
if (doSort) { if (doSort) {
sortEntitiesList(entities); sortEntitiesList(entities);
} }
entitiesFilter.clearFuzzySearchCaches();
return entities; return entities;
} }

View File

@ -55,6 +55,19 @@ export function isFilterState(a, b) {
return aKey === bKey; return aKey === bKey;
} }
/**
* The fuzzy search categories
* @type {{ characters: string, worldInfo: string, personas: string, tags: string, groups: string }}
*/
export const fuzzySearchCategories = Object.freeze({
characters: 'characters',
worldInfo: 'worldInfo',
personas: 'personas',
tags: 'tags',
groups: 'groups',
});
/** /**
* Helper class for filtering data. * Helper class for filtering data.
* @example * @example
@ -72,6 +85,12 @@ export class FilterHelper {
*/ */
scoreCache; scoreCache;
/**
* Cache for fuzzy search results per category.
* @type {Object.<string, { resultMap: Map<string, any> }>}
*/
fuzzySearchCaches;
/** /**
* Creates a new FilterHelper * Creates a new FilterHelper
* @param {Function} onDataChanged Callback to trigger when the filter data changes * @param {Function} onDataChanged Callback to trigger when the filter data changes
@ -79,6 +98,13 @@ export class FilterHelper {
constructor(onDataChanged) { constructor(onDataChanged) {
this.onDataChanged = onDataChanged; this.onDataChanged = onDataChanged;
this.scoreCache = new Map(); this.scoreCache = new Map();
this.fuzzySearchCaches = {
[fuzzySearchCategories.characters]: { resultMap: new Map() },
[fuzzySearchCategories.worldInfo]: { resultMap: new Map() },
[fuzzySearchCategories.personas]: { resultMap: new Map() },
[fuzzySearchCategories.tags]: { resultMap: new Map() },
[fuzzySearchCategories.groups]: { resultMap: new Map() },
};
} }
/** /**
@ -151,7 +177,7 @@ export class FilterHelper {
return data; return data;
} }
const fuzzySearchResults = fuzzySearchWorldInfo(data, term); const fuzzySearchResults = fuzzySearchWorldInfo(data, term, this.fuzzySearchCaches);
this.cacheScores(FILTER_TYPES.WORLD_INFO_SEARCH, new Map(fuzzySearchResults.map(i => [i.item?.uid, i.score]))); this.cacheScores(FILTER_TYPES.WORLD_INFO_SEARCH, new Map(fuzzySearchResults.map(i => [i.item?.uid, i.score])));
const filteredData = data.filter(entity => fuzzySearchResults.find(x => x.item === entity)); const filteredData = data.filter(entity => fuzzySearchResults.find(x => x.item === entity));
@ -170,7 +196,7 @@ export class FilterHelper {
return data; return data;
} }
const fuzzySearchResults = fuzzySearchPersonas(data, term); const fuzzySearchResults = fuzzySearchPersonas(data, term, this.fuzzySearchCaches);
this.cacheScores(FILTER_TYPES.PERSONA_SEARCH, new Map(fuzzySearchResults.map(i => [i.item.key, i.score]))); this.cacheScores(FILTER_TYPES.PERSONA_SEARCH, new Map(fuzzySearchResults.map(i => [i.item.key, i.score])));
const filteredData = data.filter(name => fuzzySearchResults.find(x => x.item.key === name)); const filteredData = data.filter(name => fuzzySearchResults.find(x => x.item.key === name));
@ -289,9 +315,9 @@ export class FilterHelper {
// Save fuzzy search results and scores if enabled // Save fuzzy search results and scores if enabled
if (power_user.fuzzy_search) { if (power_user.fuzzy_search) {
const fuzzySearchCharactersResults = fuzzySearchCharacters(searchValue); const fuzzySearchCharactersResults = fuzzySearchCharacters(searchValue, this.fuzzySearchCaches);
const fuzzySearchGroupsResults = fuzzySearchGroups(searchValue); const fuzzySearchGroupsResults = fuzzySearchGroups(searchValue, this.fuzzySearchCaches);
const fuzzySearchTagsResult = fuzzySearchTags(searchValue); const fuzzySearchTagsResult = fuzzySearchTags(searchValue, this.fuzzySearchCaches);
this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchCharactersResults.map(i => [`character.${i.refIndex}`, i.score]))); this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchCharactersResults.map(i => [`character.${i.refIndex}`, i.score])));
this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchGroupsResults.map(i => [`group.${i.item.id}`, i.score]))); this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchGroupsResults.map(i => [`group.${i.item.id}`, i.score])));
this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchTagsResult.map(i => [`tag.${i.item.id}`, i.score]))); this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchTagsResult.map(i => [`tag.${i.item.id}`, i.score])));
@ -343,11 +369,14 @@ export class FilterHelper {
* @param {object} options - Optional call parameters * @param {object} options - Optional call parameters
* @param {boolean} [options.clearScoreCache=true] - Whether the score cache should be cleared. * @param {boolean} [options.clearScoreCache=true] - Whether the score cache should be cleared.
* @param {Object.<FilterType, any>} [options.tempOverrides={}] - Temporarily override specific filters for this filter application * @param {Object.<FilterType, any>} [options.tempOverrides={}] - Temporarily override specific filters for this filter application
* @param {boolean} [options.clearFuzzySearchCaches=true] - Whether the fuzzy search caches should be cleared.
* @returns {any[]} The filtered data. * @returns {any[]} The filtered data.
*/ */
applyFilters(data, { clearScoreCache = true, tempOverrides = {} } = {}) { applyFilters(data, { clearScoreCache = true, tempOverrides = {}, clearFuzzySearchCaches = true } = {}) {
if (clearScoreCache) this.clearScoreCache(); if (clearScoreCache) this.clearScoreCache();
if (clearFuzzySearchCaches) this.clearFuzzySearchCaches();
// Save original filter states // Save original filter states
const originalStates = {}; const originalStates = {};
for (const key in tempOverrides) { for (const key in tempOverrides) {
@ -411,4 +440,14 @@ export class FilterHelper {
this.scoreCache = new Map(); this.scoreCache = new Map();
} }
} }
/**
* Clears fuzzy search caches
*/
clearFuzzySearchCaches() {
for (const cache of Object.values(this.fuzzySearchCaches)) {
cache.resultMap.clear();
}
console.log('All fuzzy search caches cleared');
}
} }

View File

@ -53,6 +53,7 @@ import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandE
import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js'; import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
import { POPUP_TYPE, callGenericPopup } from './popup.js'; import { POPUP_TYPE, callGenericPopup } from './popup.js';
import { loadSystemPrompts } from './sysprompt.js'; import { loadSystemPrompts } from './sysprompt.js';
import { fuzzySearchCategories } from './filters.js';
export { export {
loadPowerUserSettings, loadPowerUserSettings,
@ -1829,27 +1830,28 @@ async function loadContextSettings() {
}); });
} }
/** /**
* Fuzzy search characters by a search term * Common function to perform fuzzy search with optional caching
* @param {string} type - Type of search from fuzzySearchCategories
* @param {any[]} data - Data array to search in
* @param {Array<{name: string, weight: number, getFn?: Function}>} keys - Fuse.js keys configuration
* @param {string} searchValue - The search term * @param {string} searchValue - The search term
* @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
* @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score
*/ */
export function fuzzySearchCharacters(searchValue) { function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) {
// Check cache if provided
if (fuzzySearchCaches) {
const cache = fuzzySearchCaches[type];
if (cache?.resultMap.has(searchValue)) {
return cache.resultMap.get(searchValue);
}
}
// @ts-ignore // @ts-ignore
const fuse = new Fuse(characters, { const fuse = new Fuse(data, {
keys: [ keys: keys,
{ name: 'data.name', weight: 20 },
{ name: '#tags', weight: 10, getFn: (character) => getTagsList(character.avatar).map(x => x.name).join('||') },
{ name: 'data.description', weight: 3 },
{ name: 'data.mes_example', weight: 3 },
{ name: 'data.scenario', weight: 2 },
{ name: 'data.personality', weight: 2 },
{ name: 'data.first_mes', weight: 2 },
{ name: 'data.creator_notes', weight: 2 },
{ name: 'data.creator', weight: 1 },
{ name: 'data.tags', weight: 1 },
{ name: 'data.alternate_greetings', weight: 1 },
],
includeScore: true, includeScore: true,
ignoreLocation: true, ignoreLocation: true,
useExtendedSearch: true, useExtendedSearch: true,
@ -1857,109 +1859,110 @@ export function fuzzySearchCharacters(searchValue) {
}); });
const results = fuse.search(searchValue); const results = fuse.search(searchValue);
console.debug('Characters fuzzy search results for ' + searchValue, results);
// Store in cache if provided
if (fuzzySearchCaches) {
fuzzySearchCaches[type].resultMap.set(searchValue, results);
}
return results; return results;
} }
/**
* Fuzzy search characters by a search term
* @param {string} searchValue - The search term
* @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
* @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score
*/
export function fuzzySearchCharacters(searchValue, fuzzySearchCaches = null) {
const keys = [
{ name: 'data.name', weight: 20 },
{ name: '#tags', weight: 10, getFn: (character) => getTagsList(character.avatar).map(x => x.name).join('||') },
{ name: 'data.description', weight: 3 },
{ name: 'data.mes_example', weight: 3 },
{ name: 'data.scenario', weight: 2 },
{ name: 'data.personality', weight: 2 },
{ name: 'data.first_mes', weight: 2 },
{ name: 'data.creator_notes', weight: 2 },
{ name: 'data.creator', weight: 1 },
{ name: 'data.tags', weight: 1 },
{ name: 'data.alternate_greetings', weight: 1 },
];
return performFuzzySearch(fuzzySearchCategories.characters, characters, keys, searchValue, fuzzySearchCaches);
}
/** /**
* Fuzzy search world info entries by a search term * Fuzzy search world info entries by a search term
* @param {*[]} data - WI items data array * @param {*[]} data - WI items data array
* @param {string} searchValue - The search term * @param {string} searchValue - The search term
* @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
* @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score
*/ */
export function fuzzySearchWorldInfo(data, searchValue) { export function fuzzySearchWorldInfo(data, searchValue, fuzzySearchCaches = null) {
// @ts-ignore const keys = [
const fuse = new Fuse(data, { { name: 'key', weight: 20 },
keys: [ { name: 'group', weight: 15 },
{ name: 'key', weight: 20 }, { name: 'comment', weight: 10 },
{ name: 'group', weight: 15 }, { name: 'keysecondary', weight: 10 },
{ name: 'comment', weight: 10 }, { name: 'content', weight: 3 },
{ name: 'keysecondary', weight: 10 }, { name: 'uid', weight: 1 },
{ name: 'content', weight: 3 }, { name: 'automationId', weight: 1 },
{ name: 'uid', weight: 1 }, ];
{ name: 'automationId', weight: 1 },
],
includeScore: true,
ignoreLocation: true,
useExtendedSearch: true,
threshold: 0.2,
});
const results = fuse.search(searchValue); return performFuzzySearch(fuzzySearchCategories.worldInfo, data, keys, searchValue, fuzzySearchCaches);
console.debug('World Info fuzzy search results for ' + searchValue, results);
return results;
} }
/** /**
* Fuzzy search persona entries by a search term * Fuzzy search persona entries by a search term
* @param {*[]} data - persona data array * @param {*[]} data - persona data array
* @param {string} searchValue - The search term * @param {string} searchValue - The search term
* @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
* @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score
*/ */
export function fuzzySearchPersonas(data, searchValue) { export function fuzzySearchPersonas(data, searchValue, fuzzySearchCaches = null) {
data = data.map(x => ({ key: x, name: power_user.personas[x] ?? '', description: power_user.persona_descriptions[x]?.description ?? '' })); const mappedData = data.map(x => ({
// @ts-ignore key: x,
const fuse = new Fuse(data, { name: power_user.personas[x] ?? '',
keys: [ description: power_user.persona_descriptions[x]?.description ?? ''
{ name: 'name', weight: 20 }, }));
{ name: 'description', weight: 3 },
],
includeScore: true,
ignoreLocation: true,
useExtendedSearch: true,
threshold: 0.2,
});
const results = fuse.search(searchValue); const keys = [
console.debug('Personas fuzzy search results for ' + searchValue, results); { name: 'name', weight: 20 },
return results; { name: 'description', weight: 3 },
];
return performFuzzySearch(fuzzySearchCategories.personas, mappedData, keys, searchValue, fuzzySearchCaches);
} }
/** /**
* Fuzzy search tags by a search term * Fuzzy search tags by a search term
* @param {string} searchValue - The search term * @param {string} searchValue - The search term
* @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
* @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score
*/ */
export function fuzzySearchTags(searchValue) { export function fuzzySearchTags(searchValue, fuzzySearchCaches = null) {
// @ts-ignore const keys = [
const fuse = new Fuse(tags, { { name: 'name', weight: 1 },
keys: [ ];
{ name: 'name', weight: 1 },
],
includeScore: true,
ignoreLocation: true,
useExtendedSearch: true,
threshold: 0.2,
});
const results = fuse.search(searchValue); return performFuzzySearch(fuzzySearchCategories.tags, tags, keys, searchValue, fuzzySearchCaches);
console.debug('Tags fuzzy search results for ' + searchValue, results);
return results;
} }
/** /**
* Fuzzy search groups by a search term * Fuzzy search groups by a search term
* @param {string} searchValue - The search term * @param {string} searchValue - The search term
* @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
* @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score
*/ */
export function fuzzySearchGroups(searchValue) { export function fuzzySearchGroups(searchValue, fuzzySearchCaches = null) {
// @ts-ignore const keys = [
const fuse = new Fuse(groups, { { name: 'name', weight: 20 },
keys: [ { name: 'members', weight: 15 },
{ name: 'name', weight: 20 }, { name: '#tags', weight: 10, getFn: (group) => getTagsList(group.id).map(x => x.name).join('||') },
{ name: 'members', weight: 15 }, { name: 'id', weight: 1 },
{ name: '#tags', weight: 10, getFn: (group) => getTagsList(group.id).map(x => x.name).join('||') }, ];
{ name: 'id', weight: 1 },
],
includeScore: true,
ignoreLocation: true,
useExtendedSearch: true,
threshold: 0.2,
});
const results = fuse.search(searchValue); return performFuzzySearch(fuzzySearchCategories.groups, groups, keys, searchValue, fuzzySearchCaches);
console.debug('Groups fuzzy search results for ' + searchValue, results);
return results;
} }
/** /**