npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

vector-autocomplete

v0.5.0

Published

Vector autocomplete powered by transformer embeddings and cosine similarity — search by meaning, not keywords.

Readme

Vector Autocomplete

A React component that replaces the conventional substring filter inside MUI Autocomplete with vector similarity search powered by a real language model. By default everything runs entirely in the browser — no server, no API key, no data leaving the device. You can also plug in a server-side embedding service (Ollama, OpenAI, your own endpoint) when you need more control.

Typing "AI" surfaces "Machine learning model training" and "Neural network architecture design". Typing "deploy containers" surfaces "Kubernetes container orchestration" and "Docker image build pipeline". Things that would never match with a string.includes() filter.

Live demo: https://vector-autocomplete.vercel.app/


Installation

npm install vector-autocomplete

Install the peer dependencies if you don't already have them:

npm install react react-dom @mui/material @emotion/react @emotion/styled @huggingface/transformers hnswlib-wasm

Note: @huggingface/transformers is only required when using the default in-browser HuggingFace models. hnswlib-wasm is only required for hnsw search mode. If you supply a custom embedFn and use client or server search mode, you can omit both.


Choose your configuration

Two independent choices determine your setup:

  • Embedding — where text → vector conversion runs: in the browser (default, no server) or on your server via embedFn
  • Search — where the nearest-neighbour lookup runs: in the browser (client or hnsw) or on your server (server)

Find your row and jump to the linked sections — you can ignore everything else.

| Embedding | Search | Dataset | Infrastructure | Query latency | Read | |---|---|---|---|---|---| | In-browser (default) | client (default) | ≤ 5 k | None | ~10–50 ms | Usage — no extra config | | In-browser (default) | hnsw | ≤ 100 k | None | ~5–20 ms | In-browser embeddinghnsw mode | | In-browser (default) | server | Millions | Vector DB | ~50–150 ms | In-browser embeddingserver mode | | Server (embedFn) | client | ≤ 5 k | Embedding service | ~10–50 ms + embed RTT | Server-side embedding | | Server (embedFn) | hnsw | ≤ 100 k | Embedding service | ~5–20 ms + embed RTT | Server-side embeddinghnsw mode | | Server (embedFn) | server | Millions | Embedding service + Vector DB | ~50–150 ms | Server-side embeddingserver mode |

Privacy: with default in-browser embedding, nothing leaves the device in client and hnsw modes. In server mode the query vector is sent to your server. With a custom embedFn, the query text is sent to your embedding service regardless of search mode.


How it works

When the user types, the query is encoded into a fixed-length numeric vector (an embedding). Candidate options are embedded the same way and cached. Options are then ranked by cosine similarity to the query and the top-K are shown.

There are two independent dimensions you control:

  • Embedding source — where the text-to-vector conversion happens: in the browser (HuggingFace ONNX model, default) or on a server (Ollama, OpenAI, or any custom endpoint via the embedFn prop).
  • Search mode — where the nearest-neighbour lookup happens: in the browser (linear scan or HNSW Web Worker) or on your server (ANN via REST).

By default both happen in the browser with all-mpnet-base-v2.

What is a feature extraction vector?

Feature extraction (also called sentence embedding) is the process of turning a piece of text into a fixed-length array of numbers — a vector. The model is trained so that semantically similar texts produce vectors that point in roughly the same direction in that high-dimensional space. The angle between two vectors (cosine similarity) becomes a proxy for meaning similarity: a score near 1 means very related, near 0 means unrelated.

This is why typing "AI" surfaces "Machine learning" — their vectors are close even though the strings share no characters. A plain string.includes() filter has no concept of meaning; it can only match substrings.

Choosing a model

The default is all-mpnet-base-v2 (85 MB, 768 dims) — the best accuracy/size balance for most use cases. Switch to all-MiniLM-L6-v2 (23 MB, 384 dims) if you need a faster cold-start or are in a bandwidth-constrained environment.

| | all-MiniLM-L6-v2 | all-mpnet-base-v2 (default) | |---|---|---| | Size | 23 MB (quantized ONNX) | 85 MB (quantized ONNX) | | Vector dimensions | 384 | 768 | | Latency (WASM) | ~10–30 ms per query | ~20–60 ms per query | | Accuracy | Good for most use cases | Better |

Both models were distilled from larger transformers to be fast enough for in-browser use. all-MiniLM-L6-v2 prioritises size and speed; all-mpnet-base-v2 produces richer embeddings that better capture semantic nuance.

Default (in-browser embedding + client search):

user types "spicy noodles"
       │
       ▼
  embed("spicy noodles")  →  vector A          (browser: all-mpnet-base-v2, ONNX/WASM)
       │
       ▼
  embed(each option)      →  vector B₁…Bₙ     (browser: cached after first query)
       │
       ▼
  cosine_similarity(A, Bᵢ) for each option
       │
       ▼
  sort descending, take topK                   (shown in MUI dropdown)

With server-side embedding (embedFn + server search mode):

user types "spicy noodles"
       │
       ▼
  embedFn("spicy noodles") → POST /embed       (your embedding service)
       │                  ← vector A
       ▼
  POST /search  { vector: A, k: 8 }            (your search endpoint)
       │       ← { results: [...] }
       ▼
  show top-K results                           (shown in MUI dropdown)

The default model (~85 MB, quantized ONNX) is downloaded once from the HuggingFace CDN and cached in the browser. All subsequent uses are instant and offline.


Tech stack

| Layer | Library | |---|---| | UI | MUI (@mui/material) | | Inference | @huggingface/transformers (Transformers.js v3) | | Model | Xenova/all-mpnet-base-v2 — 85 MB quantized ONNX (HuggingFace Hub) | | ANN index | hnswlib-wasm — HNSW in WebAssembly | | Runtime | WebAssembly (ONNX Runtime Web + Emscripten) — no GPU required | | Language | TypeScript | | Bundler | Vite |


Getting started (local development)

Requirements: Node.js 18+

git clone https://github.com/adityadutta/vector-autocomplete
cd vector-autocomplete
npm install
npm run dev

Open http://localhost:5173. The model downloads on first use and is cached by the browser for all future visits.


Usage

import VectorAutocomplete from 'vector-autocomplete'

const FOODS = [
  'Spaghetti carbonara',
  'Chicken tikka masala',
  'Avocado toast with poached eggs',
  // ...
]

function App() {
  return (
    <VectorAutocomplete
      options={FOODS}
      label="Search recipes"
      topK={5}
      onChange={(value) => console.log('Selected:', value)}
    />
  )
}

See the examples/ directory for complete, copy-pasteable examples covering OpenAI embeddings, Ollama, server mode, HNSW pre-built indexes, form integration, and more.

Props

| Prop | Type | Default | Description | |---|---|---|---| | options | string[] | [] | Full list of options to rank (used by client and hnsw build-mode) | | label | string | 'Search' | Text field label | | topK | number | 8 | Maximum number of results to show | | threshold | number | 0 | Minimum cosine similarity score to include (range 01). Applies only to client mode | | model | ModelId | 'Xenova/all-mpnet-base-v2' | HuggingFace in-browser model — see In-browser embedding. Ignored when embedFn is set | | embedFn | (text: string) => Promise<number[]> | — | Server-side embedding function — bypasses HuggingFace entirely. See Server-side embedding | | searchMode | SearchMode | { type: 'client' } | Search backend — see Search modes | | onChange | (value: string \| null) => void | — | Called when the user selects an option |


Embedding

The component needs to convert text into vectors. You choose where that conversion happens:

| Source | How | Use when | |---|---|---| | In-browser (HuggingFace) | ONNX model runs in a browser Web Worker | Default — no server required | | Server-side (Ollama / custom) | Your embedFn calls a local or remote API | HuggingFace CDN blocked, need a specific model, or want to share embeddings with other services |

The embedding source is independent of the search mode. You can, for example, use an Ollama embedding function with client search mode (vectors stay in the browser), or pair a custom embedFn with server search mode (full server-side pipeline).

In-browser embedding (HuggingFace)

By default the component downloads and runs a quantized ONNX sentence-embedding model via @huggingface/transformers. No server, no API key — everything runs in a browser Web Worker.

Only feature-extraction models work here. Text-generation models (Llama, Gemma, etc.) produce text, not vectors, and are not compatible. Models must have ONNX weights on HuggingFace Hub — the Xenova and onnx-community namespaces are the primary sources.

| Model | Size | Dimensions | Notes | |---|---|---|---| | Xenova/all-MiniLM-L6-v2 | 23 MB | 384 | Fastest | | Xenova/all-mpnet-base-v2 | 85 MB | 768 | Default — best accuracy/size balance | | Xenova/paraphrase-multilingual-MiniLM-L12-v2 | 118 MB | 384 | Multilingual | | Xenova/bge-small-en-v1.5 | 50 MB | 384 | BGE small, English | | Xenova/bge-large-en-v1.5 | 250 MB | 1024 | BGE large, highest accuracy |

Pass the model ID via the model prop:

// Fast, 23 MB — good for most use cases
<VectorAutocomplete
  options={MY_OPTIONS}
  model="Xenova/all-MiniLM-L6-v2"
/>

// Multilingual — supports 50+ languages
<VectorAutocomplete
  options={MY_OPTIONS}
  model="Xenova/paraphrase-multilingual-MiniLM-L12-v2"
/>

// Highest accuracy, 250 MB
<VectorAutocomplete
  options={MY_OPTIONS}
  model="Xenova/bge-large-en-v1.5"
/>

Important: when using hnsw mode with a pre-built index, the dim field must match the embedding model's output dimension. If you switch models you must rebuild the index.


Server-side embedding

If HuggingFace is blocked (e.g. corporate firewall), you need a specific model not available on HuggingFace Hub, or you want the embedding to happen on a server, pass embedFn instead of model. The prop accepts any async function that takes a string and returns a number[] vector.

import { useCallback } from 'react'
import VectorAutocomplete from 'vector-autocomplete'

function App() {
  const embedFn = useCallback(async (text: string): Promise<number[]> => {
    const res = await fetch('https://embeddings.internal.example.com/embed', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text }),
    })
    const { vector } = await res.json()
    return vector
  }, [])

  return (
    <VectorAutocomplete
      options={MY_OPTIONS}
      embedFn={embedFn}
    />
  )
}

Note: Wrap embedFn in useCallback (or define it outside the component) to avoid re-triggering effects on every render. All vectors returned must have the same dimension. If you use hnsw mode with a pre-built index, dim must match the dimension your embedding service produces.

Ollama (local, no API key)

Ollama runs embedding models locally — no account, no API key, no data leaving the machine. This is the best option for air-gapped or restricted corporate environments where the HuggingFace CDN is blocked.

1. Install Ollama

brew install ollama        # macOS
# or download from https://ollama.com/download

2. Start Ollama with CORS enabled

The browser needs cross-origin access to the local Ollama server:

OLLAMA_ORIGINS=* ollama serve

3. Pull an embedding model

| Model | Dimensions | Command | |---|---|---| | nomic-embed-text | 768 | ollama pull nomic-embed-text | | all-minilm | 384 | ollama pull all-minilm | | mxbai-embed-large | 1024 | ollama pull mxbai-embed-large |

ollama pull nomic-embed-text

4. Use it with embedFn

import { useCallback } from 'react'
import VectorAutocomplete from 'vector-autocomplete'

function App() {
  const embedFn = useCallback(async (text: string): Promise<number[]> => {
    const res = await fetch('http://localhost:11434/api/embeddings', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ model: 'nomic-embed-text', prompt: text }),
    })
    if (!res.ok) throw new Error(`Ollama error: ${res.status}`)
    const { embedding } = await res.json()
    return embedding
  }, [])

  return (
    <VectorAutocomplete
      options={MY_OPTIONS}
      embedFn={embedFn}
    />
  )
}

Tip: If you switch Ollama models, make sure all options and queries use the same model. Vectors from different models are not comparable.

Combining server-side embedding with server search mode

When you pass both embedFn and searchMode={{ type: 'server', ... }}, the full pipeline is server-side: the component calls embedFn to embed the query, then posts the resulting vector to your search endpoint. Raw query text never touches your search server.

import { useCallback } from 'react'
import VectorAutocomplete from 'vector-autocomplete'

function App() {
  const embedFn = useCallback(async (text: string): Promise<number[]> => {
    const res = await fetch('https://embed.internal.example.com/embed', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: 'Bearer <token>' },
      body: JSON.stringify({ text }),
    })
    const { embedding } = await res.json()
    return embedding
  }, [])

  return (
    <VectorAutocomplete
      options={[]}   // not used in server search mode
      embedFn={embedFn}
      searchMode={{
        type: 'server',
        endpoint: 'https://search.internal.example.com/search',
        headers: { Authorization: 'Bearer <token>' },
      }}
    />
  )
}

Search modes

The searchMode prop controls how results are ranked. Switch modes based on dataset size and infrastructure constraints.

Comparison

| Mode | Dataset size | Infrastructure | Latency | |---|---|---|---| | client | Up to ~5 k | None | ~10–50 ms | | server | Millions | Vector DB required | ~50–150 ms (network) | | hnsw | Up to ~100 k | None | ~5–20 ms after index loads |


client (default)

All search happens in the browser. Options are embedded once and cached. Every query does a linear cosine scan over all options on the main thread.

<VectorAutocomplete
  options={MY_OPTIONS}
  searchMode={{ type: 'client' }}
/>

Best for: small to medium lists (up to ~5 k items).


server — server-side ANN

The query vector is sent to your server via a single POST request. The server performs approximate nearest-neighbour (ANN) lookup over pre-computed embeddings and returns the top-K labels.

By default the query is embedded in the browser before being sent. To move embedding to the server too, pair this mode with a server-side embedFn — the function is called first, then the resulting vector is forwarded to the search endpoint.

<VectorAutocomplete
  options={[]}   // options not used in server mode
  searchMode={{
    type: 'server',
    endpoint: 'https://your-api.example.com/search',
    headers: { Authorization: 'Bearer <token>' },  // optional
  }}
/>

Server contract

POST /search
Content-Type: application/json

{ "vector": [0.12, -0.34, ...],  "k": 8 }

→ 200 OK
{ "results": ["Option A", "Option B", ...] }

Example server (Python / FastAPI + Qdrant)

from fastapi import FastAPI
from qdrant_client import QdrantClient

app = FastAPI()
client = QdrantClient(url="http://localhost:6333")

@app.post("/search")
async def search(body: dict):
    hits = client.search(
        collection_name="options",
        query_vector=body["vector"],
        limit=body["k"],
    )
    return {"results": [hit.payload["label"] for hit in hits]}

Example server (Python / FastAPI + Oracle AI Vector Search)

If you are already on Oracle Database 23ai, you can skip a separate vector DB entirely — Oracle has native vector support built in via Oracle AI Vector Search.

from fastapi import FastAPI
import oracledb, os

app = FastAPI()
pool = oracledb.create_pool(
    user=os.environ["DB_USER"],
    password=os.environ["DB_PASSWORD"],
    dsn=os.environ["DB_DSN"],
)

@app.post("/search")
async def search(body: dict):
    vector_literal = "[" + ",".join(str(x) for x in body["vector"]) + "]"
    sql = """
        SELECT label
        FROM options_embeddings
        ORDER BY VECTOR_DISTANCE(embedding, TO_VECTOR(:vec), COSINE)
        FETCH APPROX FIRST :k ROWS ONLY
    """
    with pool.acquire() as conn:
        rows = conn.execute(sql, vec=vector_literal, k=body["k"]).fetchall()
    return {"results": [r[0] for r in rows]}

Oracle 23ai supports both HNSW and IVF ANN indexes. Create one after loading your data (see the pre-loading section below for how to embed and insert rows):

-- HNSW index (recommended for read-heavy workloads)
CREATE VECTOR INDEX idx_hnsw ON options_embeddings (embedding)
  ORGANIZATION INMEMORY NEIGHBOR GRAPH
  DISTANCE COSINE WITH TARGET ACCURACY 95;

Oracle also lets you combine vector search with regular SQL predicates in a single query — useful if your options have structured metadata (category, tenant, permissions, etc.).

Pre-loading options: the model-matching requirement

Critical: the model used to embed your stored options must be identical to the model the component uses to embed queries. Vectors from different models live in incompatible vector spaces — comparing them produces meaningless results even if dimensions happen to match.

The browser uses models from the Xenova/ HuggingFace namespace, which are ONNX exports of standard sentence-transformers models — same weights, different runtime. To pre-embed options on the server you can use the Python sentence-transformers library with the corresponding model name, or call any API that serves the same underlying model.

| model prop (browser) | Python SentenceTransformer(...) | Dims | |---|---|---| | Xenova/all-mpnet-base-v2 (default) | 'all-mpnet-base-v2' | 768 | | Xenova/all-MiniLM-L6-v2 | 'all-MiniLM-L6-v2' | 384 | | Xenova/paraphrase-multilingual-MiniLM-L12-v2 | 'paraphrase-multilingual-MiniLM-L12-v2' | 384 | | Xenova/bge-small-en-v1.5 | 'BAAI/bge-small-en-v1.5' | 384 | | Xenova/bge-large-en-v1.5 | 'BAAI/bge-large-en-v1.5' | 1024 | | custom embedFn (Ollama, OpenAI, etc.) | use the same API + model your embedFn calls | varies |

Pooling and normalization must also match. The component always uses pooling: 'mean' and normalize: true. In Python, pass normalize_embeddings=True to model.encode() — this is already shown in the examples below.

When using the default HuggingFace model (Xenova/all-mpnet-base-v2, 768 dims), pre-embed with Python sentence-transformers and load into your vector DB:

PostgreSQL + pgvector:

from sentence_transformers import SentenceTransformer
import psycopg2

model = SentenceTransformer('all-mpnet-base-v2')
labels = ['Option A', 'Option B', ...]
embeddings = model.encode(labels, normalize_embeddings=True).tolist()

conn = psycopg2.connect("postgresql://user:pass@localhost/mydb")
cur = conn.cursor()
# CREATE EXTENSION vector;
# CREATE TABLE options_embeddings (label TEXT, embedding vector(768));
cur.executemany(
    "INSERT INTO options_embeddings (label, embedding) VALUES (%s, %s)",
    [(label, embedding) for label, embedding in zip(labels, embeddings)],
)
conn.commit()

Oracle Database 23ai (AI Vector Search):

from sentence_transformers import SentenceTransformer
import oracledb, os

model = SentenceTransformer('all-mpnet-base-v2')
labels = ['Option A', 'Option B', ...]
embeddings = model.encode(labels, normalize_embeddings=True).tolist()

pool = oracledb.create_pool(user=os.environ["DB_USER"], password=os.environ["DB_PASSWORD"], dsn=os.environ["DB_DSN"])
# CREATE TABLE options_embeddings (label VARCHAR2(500), embedding VECTOR(768, FLOAT32));
with pool.acquire() as conn:
    rows = [(label, "[" + ",".join(str(x) for x in vec) + "]") for label, vec in zip(labels, embeddings)]
    conn.executemany("INSERT INTO options_embeddings VALUES (:1, TO_VECTOR(:2))", rows)
    conn.commit()

When using a custom embedFn (e.g. Ollama or OpenAI), use the same model to embed options before inserting. The DB insert is identical — only the embedding step changes:

# Ollama example — must use the same model as your client-side embedFn
import requests, psycopg2

def embed(text: str) -> list[float]:
    res = requests.post('http://localhost:11434/api/embeddings',
                        json={'model': 'nomic-embed-text', 'prompt': text})
    return res.json()['embedding']  # 768 dims for nomic-embed-text

labels = ['Option A', 'Option B', ...]
embeddings = [embed(label) for label in labels]

conn = psycopg2.connect("postgresql://user:pass@localhost/mydb")
cur = conn.cursor()
# CREATE TABLE options_embeddings (label TEXT, embedding vector(768));
cur.executemany(
    "INSERT INTO options_embeddings (label, embedding) VALUES (%s, %s)",
    [(label, embedding) for label, embedding in zip(labels, embeddings)],
)
conn.commit()

Best for: very large datasets (millions of items), or when embeddings must stay server-side.


hnsw — HNSW Web Worker

An HNSW (Hierarchical Navigable Small World) index runs inside a dedicated Web Worker powered by hnswlib-wasm. The main thread is never blocked. Queries return in ~5–20 ms once the index is ready.

Two sub-modes:

Build at runtime (demos / moderate datasets)

Leave indexUrl unset. The component embeds the options prop in the browser and builds the HNSW index in the worker automatically. Index build time is proportional to the number of options (~1–2 s for 1 k items, ~30–60 s for 10 k items).

<VectorAutocomplete
  options={MY_OPTIONS}          // embedded and indexed automatically
  searchMode={{ type: 'hnsw' }}
/>

Load pre-built binary (large datasets / production)

For 100 k+ items, pre-build the index offline and serve it as a static binary file. Provide both indexUrl and labels (the ordered list of display strings corresponding to HNSW integer labels).

<VectorAutocomplete
  options={[]}                  // not used when indexUrl is set
  searchMode={{
    type: 'hnsw',
    indexUrl: '/assets/my-index.hnsw',
    labels: ALL_LABELS,         // string[] in the same order as the index
    dim: 384,                   // must match the embedding model
    maxElements: 100_000,
  }}
/>

Building the index offline (Node.js script)

import { loadHnswlib } from 'hnswlib-wasm'
import { pipeline } from '@huggingface/transformers'
import { writeFileSync } from 'fs'

const items = [/* your 100k strings */]
const embed = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2')

const hnswlib = await loadHnswlib()
const index = new hnswlib.HierarchicalNSW('cosine', 384, '/index.bin')
index.initIndex(items.length, 16, 200, 100)

for (let i = 0; i < items.length; i++) {
  const out = await embed(items[i], { pooling: 'mean', normalize: true })
  index.addPoint(Array.from(out.data), i, false)
}

await index.writeIndex('/index.bin')
const bytes = hnswlib.FS.readFile('/index.bin')
writeFileSync('public/assets/my-index.hnsw', bytes)
writeFileSync('public/assets/my-index-labels.json', JSON.stringify(items))

Serve the .hnsw binary from your CDN or static host and load it via indexUrl.

Best for: client-side-only architectures with datasets up to ~100 k items.


Notes on performance

  • client mode runs on the main thread. For lists above ~1 k items, consider switching to hnsw mode to keep the UI responsive.
  • HNSW index build time scales with the number of options and embedding speed. For the runtime-build sub-mode, the component shows "Building HNSW index…" while the index is being constructed.
  • Options are re-embedded and the HNSW index is rebuilt if the options array reference changes. Stabilise the reference with useMemo or a module-level constant.
  • The threshold prop filters weak matches in client mode. A value of 0.150.25 removes unrelated noise while keeping semantically close results.
  • For server mode, ensure your endpoint sets Access-Control-Allow-Origin appropriately if it lives on a different origin.

Project structure

vector-autocomplete/
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
└── src/
    ├── main.tsx                        # React entry point
    ├── App.tsx                         # Demo: tech topics + food/recipes + mode selector
    ├── searchModes.ts                  # SearchMode type definitions
    ├── useEmbedder.ts                  # HuggingFace pipeline hook + cosine similarity
    ├── useServerSearch.ts              # Server-mode search hook
    ├── useHnswSearch.ts                # HNSW Web Worker search hook
    ├── workers/
    │   └── hnsw.worker.ts             # Web Worker: build or load HNSW index, serve queries
    └── components/
        └── VectorAutocomplete.tsx   # The component (all three modes)

License

MIT — see LICENSE for details.