@m31coding/fuzzy-search

repository·main·Indexed 22 days ago

https://github.com/m31coding/fuzzy-search

A fast, accurate, and multilingual frontend library for fuzzy, substring, and prefix searching of objects. It is dependency-free and uses a sorted n-gram approach for fuzzy search and a suffix array for substring and prefix searches. The library includes tools like DefaultDynamicSearcher for combining search strategies, FastEntitySearcher for performance optimization, and TimingDynamicSearcher for profiling execution time.

Tokens
13.4K
Snippets
48
Records
68
Agent score
77%

What's inside @m31coding/fuzzy-search

  1. How substring and prefix search works

    main

    Substring and prefix searches are implemented using a suffix array.

    Quality Calculation:

    • Base Quality: Calculated as query_length / term_length.
    • Prefix Match: Receives a quality offset of +2.
    • Substring Match: Receives a quality offset of +1.

    Example:

    • Query sa against term sarah (Prefix): 2 / 5 + 2 = 2.4
    • Query ara against term sarah (Substring): 3 / 5 + 1 = 1.6
    config.substringSearchConfig.suffixArraySeparator = '$';
  2. How searchers are combined

    main

    The library combines results from different searchers (Prefix, Substring, and Fuzzy) using a weighted quality approach. This ensures that the most relevant matches appear first based on the following hierarchy:

    1. Prefix Matches: Highest priority (Quality + 2).
    2. Substring Matches: Medium priority (Quality + 1).
    3. Fuzzy Matches: Lowest priority (Original quality, used primarily when no prefix or substring matches are found).

    This ranking assumes that for a given query length, a prefix match is more semantically relevant than a substring match, and fuzzy matches are fallback mechanisms for typos.

  3. How normalization works

    main

    All query strings and data terms pass through a normalization pipeline to ensure consistent matching. The order of operations is:

    1. Replace null and undefined with an empty string.
    2. Lowercase and normalize to NFKC (compatibility decomposition followed by canonical re-composition).
    3. Apply character replacements (e.g., å -> aa, æ -> ae) using LatinReplacements.
    4. Normalize to NFKD (compatibility decomposition without re-composition).
    5. Replace space-equivalent characters (e.g., _, -, /, ,, \t) with a standard space.
    6. Remove surrogate characters, padding characters, and other non-allowed characters.

    By default, allowCharacter only permits alphanumeric characters.

  4. How fuzzy search works using sorted n-grams

    main

    The fuzzy search implementation uses a sorted n-gram approach to handle typos and transpositions efficiently without the overhead of Levenshtein distance.

    The Process:

    1. Padding: Strings are padded on the left, right, and middle (replacing spaces) with specific characters (default: $$, !, and !$$). This gives more weight to the beginning of the string.
    2. N-gram Generation: The padded string is broken into n-grams (default n=3).
    3. Filtering & Sorting: N-grams that end with $ are removed. N-grams that do not contain $ have their characters sorted alphabetically. This increases the match rate for transposition errors (e.g., 'ab' vs 'ba').
    4. Quality Calculation: Quality is calculated as (common n-grams / max(query_ngrams, term_ngrams)). An inequalityPenalty (default 5%) is applied if the query is not an exact match to the term.

    Example: For the term sarah, the padded version is $$sarah!. The resulting 3-grams (after sorting and filtering) might look like $$s, $sa, ars, aar, ahr, !ah.

    config.fuzzySearchConfig.paddingLeft = '$$';
    config.fuzzySearchConfig.paddingRight = '!';
    config.fuzzySearchConfig.paddingMiddle = '!$$';
    config.fuzzySearchConfig.ngramN = 3;
    config.fuzzySearchConfig.transformNgram = (ngram) =>
      ngram.endsWith('$') ? null
      : ngram.indexOf('$') === -1 ? ngram.split('').sort().join('')
      : ngram;
    config.fuzzySearchConfig.inequalityPenalty = 0.05;
  5. Configure search for non-Latin scripts

    main

    The default configuration filters characters based on alphanumeric checks. If your dataset contains non-Latin scripts (e.g., Arabic, Cyrillic, Greek, Han), you must adjust the allowCharacter setting in the config before creating the searcher.

    const config = fuzzySearch.Config.createDefaultConfig();
    // Allow all characters for non-latin scripts
    config.normalizerConfig.allowCharacter = (_c) => true;
    const searcher = fuzzySearch.SearcherFactory.createSearcher(config);
  6. Configure fuzzy search settings

    main

    You can customize the fuzzy search behavior by modifying the fuzzySearchConfig object. Key options include:

    • paddingLeft: Character(s) padded to the start of the string.
    • paddingRight: Character(s) padded to the end of the string.
    • paddingMiddle: Character(s) used to replace spaces for multi-word matching.
    • ngramN: The size of the n-grams to generate.
    • transformNgram: A function to process each n-gram (used for sorting or filtering).
    • inequalityPenalty: A decimal value representing the penalty applied when the query is not an exact match.
    config.fuzzySearchConfig.paddingLeft = '$$';
    config.fuzzySearchConfig.paddingRight = '!';
    config.fuzzySearchConfig.paddingMiddle = '!$$';
    config.fuzzySearchConfig.ngramN = 3;
    config.fuzzySearchConfig.transformNgram = (ngram) =>
      ngram.endsWith('$') ? null
      : ngram.indexOf('$') === -1 ? ngram.split('').sort().join('')
      : ngram;
    config.fuzzySearchConfig.inequalityPenalty = 0.05;
  7. FuzzySearcher indexing and search lifecycle

    main

    The FuzzySearcher follows a specific lifecycle to ensure efficient searching:

    1. Initialization: A FuzzySearcher is instantiated with a specific NgramComputer strategy.
    2. Indexing: When .index(terms) is called, the searcher clears any previous index, computes n-grams for every valid term, and populates an InvertedIndex. The index is then .seal()ed to optimize for lookups.
    3. Searching: When .getMatches(query) is called:
      • The query string is converted into n-grams.
      • The searcher calculates commonNgramCounts (the overlap between query n-grams and indexed terms).
      • It uses QualityComputer.ComputeOverlapMaxCoefficient to determine a quality score for each term.
      • Only terms with a quality score strictly greater than query.minQuality are returned.
    4. Persistence: The state can be captured via a Memento and restored later, which is useful for avoiding re-indexing expensive datasets.
  8. Configure substring search settings

    main

    The substring search behavior can be adjusted via substringSearchConfig. The primary setting is:

    • suffixArraySeparator: The character used to separate entries in the suffix array.
    config.substringSearchConfig.suffixArraySeparator = '$';
  9. Basic usage of @m31coding/fuzzy-search

    main

    To use the library, create a searcher using SearcherFactory.createDefaultSearcher(), index your entities with indexEntities, and perform searches using getMatches with a Query object.

    Entities are identified by a unique ID. You provide a getId function and a getTerms function that returns an array of strings (features) to be indexed for each entity.

    import * as fuzzySearch from './path/to/fuzzy-search.module.js';
    
    const searcher = fuzzySearch.SearcherFactory.createDefaultSearcher();
    
    const persons = [
      { id: 23501, firstName: 'Alice', lastName: 'King' },
      { id: 99234, firstName: 'Bob', lastName: 'Bishop' }
    ];
    
    // Index entities
    // getId: (e) => e.id
    // getTerms: (e) => [e.firstName, e.lastName, `${e.firstName} ${e.lastName}`]
    const indexingMeta = searcher.indexEntities(
      persons,
      (e) => e.id,
      (e) => [e.firstName, e.lastName, `${e.firstName} ${e.lastName}`]
    );
    
    // Perform a search
    const result = searcher.getMatches(new fuzzySearch.Query('alice kign'));
    console.log(result.matches);
  10. Upsert and remove entities

    main

    The library supports dynamic updates to the indexed data:

    • removeEntities(ids): Removes entities by their IDs. This is implemented via blacklisting.
    • upsertEntities(entities, getId, getTerms): Adds new entities or updates existing ones.

    Performance Warning: Upserting is implemented by reindexing a secondary searcher. Repeated upsert operations on large datasets can be costly. If you perform many updates, consider calling indexEntities (or the internal index method) to reindex from scratch to maintain performance.

    // Remove entities
    const removalResult = searcher.removeEntities([99234, 5823]);
    
    // Upsert entities (add or update)
    const persons2 = [
      { id: 723, firstName: 'David', lastName: 'Knight' },
      { id: 23501, firstName: 'Allie', lastName: 'King' }
    ];
    const upsertMeta = searcher.upsertEntities(
      persons2,
      (e) => e.id,
      (e) => [e.firstName, e.lastName, `${e.firstName} ${e.lastName}`]
    );