Merge pull request #3106 from joenunezb/optimize/improve-search perf(search): improve fuzzy character search performance by ~13x (4.5s → 350ms)
Signed| @@ -1621,7 +1621,7 @@ export function getEntitiesList({ doFilter = false, doSort = true } = {}) { | ||
| 1621 | 1621 | subEntities = filterByTagState(entities, { subForEntity: entity }); |
| 1622 | 1622 | if (doFilter) { |
| 1623 | 1623 | // sub entities filter "hacked" because folder filter should not be applied there, so even in "only folders" mode characters show up |
| 1624 | 1624 | subEntities = entitiesFilter.applyFilters(subEntities, { clearScoreCache: false, tempOverrides: { [FILTER_TYPES.FOLDER]: FILTER_STATES.UNDEFINED }, clearFuzzySearchCaches: false }); |
| 1625 | 1625 | } |
| 1626 | 1626 | if (doSort) { |
| 1627 | 1627 | sortEntitiesList(subEntities); |
| @@ -1634,11 +1634,11 @@ export function getEntitiesList({ doFilter = false, doSort = true } = {}) { | ||
| 1634 | 1634 | // Second run filters, hiding whatever should be filtered later |
| 1635 | 1635 | if (doFilter) { |
| 1636 | 1636 | const beforeFinalEntities = filterByTagState(entities, { globalDisplayFilters: true }); |
| 1637 | 1637 | entities = entitiesFilter.applyFilters(beforeFinalEntities, { clearFuzzySearchCaches: false }); |
| 1638 | 1638 | |
| 1639 | 1639 | // Magic for folder filter. If that one is enabled, and no folders are display anymore, we remove that filter to actually show the characters. |
| 1640 | 1640 | if (isFilterState(entitiesFilter.getFilterData(FILTER_TYPES.FOLDER), FILTER_STATES.SELECTED) && entities.filter(x => x.type == 'tag').length == 0) { |
| 1641 | 1641 | entities = entitiesFilter.applyFilters(beforeFinalEntities, { tempOverrides: { [FILTER_TYPES.FOLDER]: FILTER_STATES.UNDEFINED }, clearFuzzySearchCaches: false }); |
| 1642 | 1642 | } |
| 1643 | 1643 | } |
| 1644 | 1644 | |
| @@ -1654,6 +1654,7 @@ export function getEntitiesList({ doFilter = false, doSort = true } = {}) { | ||
| 1654 | 1654 | if (doSort) { |
| 1655 | 1655 | sortEntitiesList(entities); |
| 1656 | 1656 | } |
| 1657 | + entitiesFilter.clearFuzzySearchCaches(); | |
| 1657 | 1658 | return entities; |
| 1658 | 1659 | } |
| 1659 | 1660 | |
| @@ -56,6 +56,19 @@ export function isFilterState(a, b) { | ||
| 56 | 56 | } |
| 57 | 57 | |
| 58 | 58 | /** |
| 59 | + * The fuzzy search categories | |
| 60 | + * @type {{ characters: string, worldInfo: string, personas: string, tags: string, groups: string }} | |
| 61 | + */ | |
| 62 | +export const fuzzySearchCategories = Object.freeze({ | |
| 63 | + characters: 'characters', | |
| 64 | + worldInfo: 'worldInfo', | |
| 65 | + personas: 'personas', | |
| 66 | + tags: 'tags', | |
| 67 | + groups: 'groups', | |
| 68 | +}); | |
| 69 | + | |
| 70 | + | |
| 71 | +/** | |
| 59 | 72 | * Helper class for filtering data. |
| 60 | 73 | * @example |
| 61 | 74 | * const filterHelper = new FilterHelper(() => console.log('data changed')); |
| @@ -73,12 +86,25 @@ export class FilterHelper { | ||
| 73 | 86 | scoreCache; |
| 74 | 87 | |
| 75 | 88 | /** |
| 89 | + * Cache for fuzzy search results per category. | |
| 90 | + * @type {Object.<string, { resultMap: Map<string, any> }>} | |
| 91 | + */ | |
| 92 | + fuzzySearchCaches; | |
| 93 | + | |
| 94 | + /** | |
| 76 | 95 | * Creates a new FilterHelper |
| 77 | 96 | * @param {Function} onDataChanged Callback to trigger when the filter data changes |
| 78 | 97 | */ |
| 79 | 98 | constructor(onDataChanged) { |
| 80 | 99 | this.onDataChanged = onDataChanged; |
| 81 | 100 | this.scoreCache = new Map(); |
| 101 | + this.fuzzySearchCaches = { | |
| 102 | + [fuzzySearchCategories.characters]: { resultMap: new Map() }, | |
| 103 | + [fuzzySearchCategories.worldInfo]: { resultMap: new Map() }, | |
| 104 | + [fuzzySearchCategories.personas]: { resultMap: new Map() }, | |
| 105 | + [fuzzySearchCategories.tags]: { resultMap: new Map() }, | |
| 106 | + [fuzzySearchCategories.groups]: { resultMap: new Map() }, | |
| 107 | + }; | |
| 82 | 108 | } |
| 83 | 109 | |
| 84 | 110 | /** |
| @@ -151,7 +177,7 @@ export class FilterHelper { | ||
| 151 | 177 | return data; |
| 152 | 178 | } |
| 153 | 179 | |
| 154 | 180 | const fuzzySearchResults = fuzzySearchWorldInfo(data, term, this.fuzzySearchCaches); |
| 155 | 181 | this.cacheScores(FILTER_TYPES.WORLD_INFO_SEARCH, new Map(fuzzySearchResults.map(i => [i.item?.uid, i.score]))); |
| 156 | 182 | |
| 157 | 183 | const filteredData = data.filter(entity => fuzzySearchResults.find(x => x.item === entity)); |
| @@ -170,7 +196,7 @@ export class FilterHelper { | ||
| 170 | 196 | return data; |
| 171 | 197 | } |
| 172 | 198 | |
| 173 | 199 | const fuzzySearchResults = fuzzySearchPersonas(data, term, this.fuzzySearchCaches); |
| 174 | 200 | this.cacheScores(FILTER_TYPES.PERSONA_SEARCH, new Map(fuzzySearchResults.map(i => [i.item.key, i.score]))); |
| 175 | 201 | |
| 176 | 202 | const filteredData = data.filter(name => fuzzySearchResults.find(x => x.item.key === name)); |
| @@ -289,9 +315,9 @@ export class FilterHelper { | ||
| 289 | 315 | |
| 290 | 316 | // Save fuzzy search results and scores if enabled |
| 291 | 317 | if (power_user.fuzzy_search) { |
| 292 | 318 | const fuzzySearchCharactersResults = fuzzySearchCharacters(searchValue, this.fuzzySearchCaches); |
| 293 | 319 | const fuzzySearchGroupsResults = fuzzySearchGroups(searchValue, this.fuzzySearchCaches); |
| 294 | 320 | const fuzzySearchTagsResult = fuzzySearchTags(searchValue, this.fuzzySearchCaches); |
| 295 | 321 | this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchCharactersResults.map(i => [`character.${i.refIndex}`, i.score]))); |
| 296 | 322 | this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchGroupsResults.map(i => [`group.${i.item.id}`, i.score]))); |
| 297 | 323 | this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchTagsResult.map(i => [`tag.${i.item.id}`, i.score]))); |
| @@ -343,11 +369,14 @@ export class FilterHelper { | ||
| 343 | 369 | * @param {object} options - Optional call parameters |
| 344 | 370 | * @param {boolean} [options.clearScoreCache=true] - Whether the score cache should be cleared. |
| 345 | 371 | * @param {Object.<FilterType, any>} [options.tempOverrides={}] - Temporarily override specific filters for this filter application |
| 372 | + * @param {boolean} [options.clearFuzzySearchCaches=true] - Whether the fuzzy search caches should be cleared. | |
| 346 | 373 | * @returns {any[]} The filtered data. |
| 347 | 374 | */ |
| 348 | 375 | applyFilters(data, { clearScoreCache = true, tempOverrides = {}, clearFuzzySearchCaches = true } = {}) { |
| 349 | 376 | if (clearScoreCache) this.clearScoreCache(); |
| 350 | 377 | |
| 378 | + if (clearFuzzySearchCaches) this.clearFuzzySearchCaches(); | |
| 379 | + | |
| 351 | 380 | // Save original filter states |
| 352 | 381 | const originalStates = {}; |
| 353 | 382 | for (const key in tempOverrides) { |
| @@ -411,4 +440,14 @@ export class FilterHelper { | ||
| 411 | 440 | this.scoreCache = new Map(); |
| 412 | 441 | } |
| 413 | 442 | } |
| 443 | + | |
| 444 | + /** | |
| 445 | + * Clears fuzzy search caches | |
| 446 | + */ | |
| 447 | + clearFuzzySearchCaches() { | |
| 448 | + for (const cache of Object.values(this.fuzzySearchCaches)) { | |
| 449 | + cache.resultMap.clear(); | |
| 450 | + } | |
| 451 | + console.log('All fuzzy search caches cleared'); | |
| 452 | + } | |
| 414 | 453 | } |
| @@ -53,6 +53,7 @@ import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandE | ||
| 53 | 53 | import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 54 | 54 | import { POPUP_TYPE, callGenericPopup } from './popup.js'; |
| 55 | 55 | import { loadSystemPrompts } from './sysprompt.js'; |
| 56 | +import { fuzzySearchCategories } from './filters.js'; | |
| 56 | 57 | |
| 57 | 58 | export { |
| 58 | 59 | loadPowerUserSettings, |
| @@ -1829,15 +1830,51 @@ async function loadContextSettings() { | ||
| 1829 | 1830 | }); |
| 1830 | 1831 | } |
| 1831 | 1832 | |
| 1833 | + | |
| 1832 | 1834 | /** |
| 1833 | 1835 | * FuzzyCommon searchfunction charactersto byperform afuzzy search termwith optional caching |
| 1836 | + * @param {string} type - Type of search from fuzzySearchCategories | |
| 1837 | + * @param {any[]} data - Data array to search in | |
| 1838 | + * @param {Array<{name: string, weight: number, getFn?: Function}>} keys - Fuse.js keys configuration | |
| 1834 | 1839 | * @param {string} searchValue - The search term |
| 1840 | + * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches | |
| 1835 | 1841 | * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score |
| 1836 | 1842 | */ |
| 1837 | -export function fuzzySearchCharacters(searchValue) { | |
| 1843 | +function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) { | |
| 1844 | + // Check cache if provided | |
| 1845 | + if (fuzzySearchCaches) { | |
| 1846 | + const cache = fuzzySearchCaches[type]; | |
| 1847 | + if (cache?.resultMap.has(searchValue)) { | |
| 1848 | + return cache.resultMap.get(searchValue); | |
| 1849 | + } | |
| 1850 | + } | |
| 1851 | + | |
| 1838 | 1852 | // @ts-ignore |
| 1839 | 1853 | const fuse = new Fuse(charactersdata, { |
| 1840 | 1854 | keys: [keys, |
| 1855 | + includeScore: true, | |
| 1856 | + ignoreLocation: true, | |
| 1857 | + useExtendedSearch: true, | |
| 1858 | + threshold: 0.2, | |
| 1859 | + }); | |
| 1860 | + | |
| 1861 | + const results = fuse.search(searchValue); | |
| 1862 | + | |
| 1863 | + // Store in cache if provided | |
| 1864 | + if (fuzzySearchCaches) { | |
| 1865 | + fuzzySearchCaches[type].resultMap.set(searchValue, results); | |
| 1866 | + } | |
| 1867 | + return results; | |
| 1868 | +} | |
| 1869 | + | |
| 1870 | +/** | |
| 1871 | + * Fuzzy search characters by a search term | |
| 1872 | + * @param {string} searchValue - The search term | |
| 1873 | + * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches | |
| 1874 | + * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score | |
| 1875 | + */ | |
| 1876 | +export function fuzzySearchCharacters(searchValue, fuzzySearchCaches = null) { | |
| 1877 | + const keys = [ | |
| 1841 | 1878 | { name: 'data.name', weight: 20 }, |
| 1842 | 1879 | { name: '#tags', weight: 10, getFn: (character) => getTagsList(character.avatar).map(x => x.name).join('||') }, |
| 1843 | 1880 | { name: 'data.description', weight: 3 }, |
| @@ -1849,28 +1886,20 @@ export function fuzzySearchCharacters(searchValue) { | ||
| 1849 | 1886 | { name: 'data.creator', weight: 1 }, |
| 1850 | 1887 | { name: 'data.tags', weight: 1 }, |
| 1851 | 1888 | { name: 'data.alternate_greetings', weight: 1 }, |
| 1852 | - ], | |
| 1889 | + ]; | |
| 1853 | - includeScore: true, | |
| 1854 | - ignoreLocation: true, | |
| 1855 | - useExtendedSearch: true, | |
| 1856 | - threshold: 0.2, | |
| 1857 | - }); | |
| 1858 | 1890 | |
| 1859 | - const results = fuse.search(searchValue); | |
| 1891 | + return performFuzzySearch(fuzzySearchCategories.characters, characters, keys, searchValue, fuzzySearchCaches); | |
| 1860 | - console.debug('Characters fuzzy search results for ' + searchValue, results); | |
| 1861 | - return results; | |
| 1862 | 1892 | } |
| 1863 | 1893 | |
| 1864 | 1894 | /** |
| 1865 | 1895 | * Fuzzy search world info entries by a search term |
| 1866 | 1896 | * @param {*[]} data - WI items data array |
| 1867 | 1897 | * @param {string} searchValue - The search term |
| 1898 | + * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches | |
| 1868 | 1899 | * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score |
| 1869 | 1900 | */ |
| 1870 | 1901 | export function fuzzySearchWorldInfo(data, searchValue, fuzzySearchCaches = null) { |
| 1871 | - // @ts-ignore | |
| 1902 | + const keys = [ | |
| 1872 | - const fuse = new Fuse(data, { | |
| 1873 | - keys: [ | |
| 1874 | 1903 | { name: 'key', weight: 20 }, |
| 1875 | 1904 | { name: 'group', weight: 15 }, |
| 1876 | 1905 | { name: 'comment', weight: 10 }, |
| @@ -1878,88 +1907,62 @@ export function fuzzySearchWorldInfo(data, searchValue) { | ||
| 1878 | 1907 | { name: 'content', weight: 3 }, |
| 1879 | 1908 | { name: 'uid', weight: 1 }, |
| 1880 | 1909 | { name: 'automationId', weight: 1 }, |
| 1881 | - ], | |
| 1910 | + ]; | |
| 1882 | - includeScore: true, | |
| 1883 | - ignoreLocation: true, | |
| 1884 | - useExtendedSearch: true, | |
| 1885 | - threshold: 0.2, | |
| 1886 | - }); | |
| 1887 | 1911 | |
| 1888 | - const results = fuse.search(searchValue); | |
| 1912 | + return performFuzzySearch(fuzzySearchCategories.worldInfo, data, keys, searchValue, fuzzySearchCaches); | |
| 1889 | - console.debug('World Info fuzzy search results for ' + searchValue, results); | |
| 1890 | - return results; | |
| 1891 | 1913 | } |
| 1892 | 1914 | |
| 1893 | 1915 | /** |
| 1894 | 1916 | * Fuzzy search persona entries by a search term |
| 1895 | 1917 | * @param {*[]} data - persona data array |
| 1896 | 1918 | * @param {string} searchValue - The search term |
| 1919 | + * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches | |
| 1897 | 1920 | * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score |
| 1898 | 1921 | */ |
| 1899 | 1922 | export function fuzzySearchPersonas(data, searchValue, fuzzySearchCaches = null) { |
| 1900 | - data = data.map(x => ({ key: x, name: power_user.personas[x] ?? '', description: power_user.persona_descriptions[x]?.description ?? '' })); | |
| 1923 | + const mappedData = data.map(x => ({ | |
| 1901 | - // @ts-ignore | |
| 1924 | + key: x, | |
| 1902 | - const fuse = new Fuse(data, { | |
| 1925 | + name: power_user.personas[x] ?? '', | |
| 1903 | - keys: [ | |
| 1926 | + description: power_user.persona_descriptions[x]?.description ?? '' | |
| 1927 | + })); | |
| 1928 | + | |
| 1929 | + const keys = [ | |
| 1904 | 1930 | { name: 'name', weight: 20 }, |
| 1905 | 1931 | { name: 'description', weight: 3 }, |
| 1906 | - ], | |
| 1932 | + ]; | |
| 1907 | - includeScore: true, | |
| 1908 | - ignoreLocation: true, | |
| 1909 | - useExtendedSearch: true, | |
| 1910 | - threshold: 0.2, | |
| 1911 | - }); | |
| 1912 | 1933 | |
| 1913 | - const results = fuse.search(searchValue); | |
| 1934 | + return performFuzzySearch(fuzzySearchCategories.personas, mappedData, keys, searchValue, fuzzySearchCaches); | |
| 1914 | - console.debug('Personas fuzzy search results for ' + searchValue, results); | |
| 1915 | - return results; | |
| 1916 | 1935 | } |
| 1917 | 1936 | |
| 1918 | 1937 | /** |
| 1919 | 1938 | * Fuzzy search tags by a search term |
| 1920 | 1939 | * @param {string} searchValue - The search term |
| 1940 | + * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches | |
| 1921 | 1941 | * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score |
| 1922 | 1942 | */ |
| 1923 | 1943 | export function fuzzySearchTags(searchValue, fuzzySearchCaches = null) { |
| 1924 | - // @ts-ignore | |
| 1944 | + const keys = [ | |
| 1925 | - const fuse = new Fuse(tags, { | |
| 1926 | - keys: [ | |
| 1927 | 1945 | { name: 'name', weight: 1 }, |
| 1928 | - ], | |
| 1946 | + ]; | |
| 1929 | - includeScore: true, | |
| 1930 | - ignoreLocation: true, | |
| 1931 | - useExtendedSearch: true, | |
| 1932 | - threshold: 0.2, | |
| 1933 | - }); | |
| 1934 | 1947 | |
| 1935 | - const results = fuse.search(searchValue); | |
| 1948 | + return performFuzzySearch(fuzzySearchCategories.tags, tags, keys, searchValue, fuzzySearchCaches); | |
| 1936 | - console.debug('Tags fuzzy search results for ' + searchValue, results); | |
| 1937 | - return results; | |
| 1938 | 1949 | } |
| 1939 | 1950 | |
| 1940 | 1951 | /** |
| 1941 | 1952 | * Fuzzy search groups by a search term |
| 1942 | 1953 | * @param {string} searchValue - The search term |
| 1954 | + * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches | |
| 1943 | 1955 | * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score |
| 1944 | 1956 | */ |
| 1945 | 1957 | export function fuzzySearchGroups(searchValue, fuzzySearchCaches = null) { |
| 1946 | - // @ts-ignore | |
| 1958 | + const keys = [ | |
| 1947 | - const fuse = new Fuse(groups, { | |
| 1948 | - keys: [ | |
| 1949 | 1959 | { name: 'name', weight: 20 }, |
| 1950 | 1960 | { name: 'members', weight: 15 }, |
| 1951 | 1961 | { name: '#tags', weight: 10, getFn: (group) => getTagsList(group.id).map(x => x.name).join('||') }, |
| 1952 | 1962 | { name: 'id', weight: 1 }, |
| 1953 | - ], | |
| 1963 | + ]; | |
| 1954 | - includeScore: true, | |
| 1955 | - ignoreLocation: true, | |
| 1956 | - useExtendedSearch: true, | |
| 1957 | - threshold: 0.2, | |
| 1958 | - }); | |
| 1959 | 1964 | |
| 1960 | - const results = fuse.search(searchValue); | |
| 1965 | + return performFuzzySearch(fuzzySearchCategories.groups, groups, keys, searchValue, fuzzySearchCaches); | |
| 1961 | - console.debug('Groups fuzzy search results for ' + searchValue, results); | |
| 1962 | - return results; | |
| 1963 | 1966 | } |
| 1964 | 1967 | |
| 1965 | 1968 | /** |