MeMemo

repository·main·Indexed 41 days ago

https://github.com/poloclub/mememo

An on-device vector database and JavaScript library for high-performance vector search and Retrieval-Augmented Generation (RAG) in the browser. It implements the Hierarchical Navigable Small World (HNSW) approximate nearest neighbor search technique using IndexedDB and Web Workers. Version 0.1.0 supports cosine similarity, bulk insertion, and index serialization.

Tokens
2.5K
Snippets
10
Records
13
Agent score
1%

What's inside MeMemo

  1. Run the RAG Playground examples

    main

    The RAG Playground contains example applications. To run them locally:

    1. Clone the repository.
    2. Navigate to the examples/rag-playground directory.
    3. Install dependencies.
    4. Run the development server.

    The application will be available at localhost:3000 in your browser.

    git clone git@github.com:poloclub/mememo.git
    cd mememo/examples/rag-playground
    npm install
    npm run dev
  2. Set up the MeMemo development environment

    main

    To contribute to the MeMemo core library, clone the repository, install dependencies, and run tests using Vitest:

    git clone git@github.com:poloclub/mememo.git
    cd mememo
    npm install
    npm run test
  3. How MeMemo selects neighbors during construction

    main

    When adding new nodes to the index, MeMemo uses a neighbor selection heuristic to ensure graph connectivity and diversity. This implementation is inspired by hnswlib and datasketch.

    Instead of simply picking the closest neighbors, the _selectNeighborsHeuristic algorithm favors candidates that are far away from already selected neighbors. This is achieved by:

    1. Sorting candidates by distance.
    2. Iterating through candidates and checking the distance between the candidate and existing selectedNeighbors.
    3. Rejecting a candidate if it is closer to an existing neighbor than it is to the new node being inserted (d(candidate, any approved candidate) < d(candidate, new node)).

    This heuristic helps prevent the graph from forming tight, redundant clusters and improves the overall search efficiency.

  4. How MeMemo performs vector search

    main

    MeMemo implements a hierarchical graph-based vector search (similar to HNSW). The search process works by iterating through graph layers from the top layer down to layer 0:

    1. Top Layers (Greedy Search): In layers where the specific node does not exist, MeMemo performs an EF=1 search. This is a greedy search that finds the single closest neighbor to serve as an entry point for the next layer down.
    2. Lower Layers (EF Search): Once the search reaches a layer containing the node (or layer 0), it performs an EF search. This search maintains a set of ef closest points to provide a more robust set of entry points and neighbors.
    3. Distance Calculation: All distance computations are performed using a user-provided distanceFunction which compares embedding values and handles optional metadata via queryKey and nodeKey.

    This hierarchical approach allows for fast logarithmic-time lookups by quickly narrowing down the search space in upper layers before performing more intensive searches in the dense bottom layers.

  5. Perform vector search and storage with HNSW

    main

    You can manage vector indices and perform approximate nearest neighbor searches using the HNSW class.

    Key operations include:

    • Initializing an index with a specific distanceFunction (e.g., 'cosine').
    • Using bulkInsert to add elements (keys and high-dimensional vectors) in batches.
    • Using query to find the k nearest neighbors for a given query vector.
    // Import the HNSW class from the MeMemo module
    import { HNSW } from 'mememo';
    
    // Creating a new index
    const index = new HNSW({ distanceFunction: 'cosine' });
    
    // Inserting elements into our index in batches
    let keys: string[];
    let values: number[][];
    await index.bulkInsert(keys, values);
    
    // Find k-nearest neighbors
    let query: number[];
    const { keys, distances } = await index.query(query, k);
  6. Configure the HNSW index

    main

    When instantiating the HNSW class, you can provide an HNSWConfig object to tune the performance and behavior of the vector search.

    Key parameters include:

    • distanceFunction: A string ('cosine' or 'cosine-normalized') or a custom function (a: number[], b: number[]) => number to calculate similarity.
    • m: The maximum number of neighbors for each node (recommended range: 5 to 48). Higher values improve recall for high-dimensional data.
    • efConstruction: The number of neighbors to consider during the graph construction's greedy search.
    • mMax0: The maximum number of connections for nodes at the first level (defaults to 2 * m).
    • ml: A normalizer parameter controlling the number of overlaps across layers.
    • useIndexedDB: If true (default), embeddings are stored in IndexedDB to save memory. If false, all embeddings are kept in memory.
    • distancePrecision: The number of decimals to store for node distances (default: 6).
    const index = new HNSW({
      distanceFunction: 'cosine',
      m: 16,
      efConstruction: 100,
      useIndexedDB: true,
      distancePrecision: 6
    });
  7. Perform vector similarity search

    main

    Use the query method to find the nearest neighbors for a given embedding.

    query(value, [k], [ef])

    • value: The query embedding (number[]).
    • k (optional): The number of nearest neighbors to return. If omitted, it returns all candidates found within the search scope.
    • ef (optional): The number of neighbors to search at each step. Defaults to efConstruction.

    Returns an object containing keys and distances for the top results.

    const results = await index.query([0.1, 0.2, 0.3], 5);
    console.log(results.keys);     // Array of matching keys
    console.log(results.distances); // Array of distances
  8. Insert elements into the HNSW index

    main

    You can add new embeddings to the index using several methods depending on your workflow:

    • insert(key, value, [maxLevel]): Inserts a single element. If the key already exists, it throws an error (use update() instead).
    • bulkInsert(keys, values, [maxLevels]): Inserts multiple elements at once for better efficiency.
    • insertSkipIndex(key, value): Inserts only the embedding for a key that is assumed to already exist in the graph structure (useful for separating graph construction from embedding storage).
    • bulkInsertSkipIndex(keys, values): Bulk version of insertSkipIndex.
    // Single insert
    await index.insert('user_1', [0.1, 0.2, 0.3]);
    
    // Bulk insert
    await index.bulkInsert(
      ['user_2', 'user_3'], 
      [[0.4, 0.5, 0.6], [0.7, 0.8, 0.9]]
    );
  9. Export and load the HNSW index

    main

    The HNSW index can be serialized to JSON for persistence. Note that exportIndex() only exports the graph structure and configuration; it does not include the actual node embeddings.

    1. Export: Use exportIndex() to get a MememoIndexJSON object.
    2. Load: Use loadIndex(mememoIndex) to restore the graph structure.
    3. Restore Embeddings: After loading the index, you must call insertSkipIndex() or bulkInsertSkipIndex() to re-populate the embeddings into the storage (IndexedDB or Memory).
    // 1. Export the graph structure
    const savedIndex = index.exportIndex();
    
    // 2. Load the structure into a new instance
    const newIndex = new HNSW({ /* config */ });
    newIndex.loadIndex(savedIndex);
    
    // 3. Re-insert embeddings (required!)
    await newIndex.bulkInsertSkipIndex(keys, values);
  10. Update or delete elements in the HNSW index

    main

    To modify or remove existing data:

    • update(key, value): Updates the embedding for an existing key. This triggers a re-indexing of the node's neighbors to maintain graph integrity.
    • markDeleted(key): Marks a node as deleted. The node remains in the graph to act as a bridge for other queries but will be excluded from search results. Future insertions will not add new edges to a deleted node.
    • unMarkDeleted(key): Recovers a previously marked deleted node.
    • clear(): Resets the entire index by clearing all graph layers and nodes.
    // Update an existing embedding
    await index.update('user_1', [0.15, 0.25, 0.35]);
    
    // Soft delete a node
    await index.markDeleted('user_1');
    
    // Restore a deleted node
    await index.unMarkDeleted('user_1');