vector-autocomplete
v0.5.0
Published
Vector autocomplete powered by transformer embeddings and cosine similarity — search by meaning, not keywords.
Maintainers
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-autocompleteInstall the peer dependencies if you don't already have them:
npm install react react-dom @mui/material @emotion/react @emotion/styled @huggingface/transformers hnswlib-wasmNote:
@huggingface/transformersis only required when using the default in-browser HuggingFace models.hnswlib-wasmis only required forhnswsearch mode. If you supply a customembedFnand useclientorserversearch 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 (
clientorhnsw) 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
clientandhnswmodes. Inservermode the query vector is sent to your server. With a customembedFn, 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
embedFnprop). - 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 devOpen 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 0–1). 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
hnswmode with a pre-built index, thedimfield 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
embedFninuseCallback(or define it outside the component) to avoid re-triggering effects on every render. All vectors returned must have the same dimension. If you usehnswmode with a pre-built index,dimmust 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/download2. Start Ollama with CORS enabled
The browser needs cross-origin access to the local Ollama server:
OLLAMA_ORIGINS=* ollama serve3. 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-text4. 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'andnormalize: true. In Python, passnormalize_embeddings=Truetomodel.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
clientmode runs on the main thread. For lists above ~1 k items, consider switching tohnswmode 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
optionsarray reference changes. Stabilise the reference withuseMemoor a module-level constant. - The
thresholdprop filters weak matches inclientmode. A value of0.15–0.25removes unrelated noise while keeping semantically close results. - For
servermode, ensure your endpoint setsAccess-Control-Allow-Originappropriately 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.
