Skip to content

thewizster/MySSE

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

25 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

MySSE — In-Memory Semantic Search Engine for TypeScript & Deno

JSR JSR Score License: MIT Deno TypeScript Zero Dependencies

A lightweight, zero-dependency vector search engine built entirely in TypeScript. Add semantic search to any Deno or TypeScript project in minutes — no databases, no external services, no infrastructure.

Quick Start · API Reference · Powers (Plugins) · Getting Started Guide


MySSE Home Page

Why MySSE?

Most vector search solutions require you to stand up Postgres + pgvector, run a Docker container for Qdrant/Milvus, or pay for a hosted service. MySSE is the opposite: import a single module and you have a working semantic search engine running entirely in-process — no network, no disk, no YAML.

Consideration MySSE Typical alternatives
Setup import { SemanticEngine } from … Docker / cloud provisioning
External services None PostgreSQL, Redis, vector DB
Dependencies Zero Dozens to hundreds
Language Pure TypeScript C++/Rust with JS bindings
Runtime Deno (secure by default) Node.js / mixed
Latency (10k docs) ~1.5 ms (HNSW) / ~5 ms (brute-force) Varies by network round-trip
Embedding models Pluggable — swap at runtime Locked at build time

If you need a self-contained, hackable search engine that you can embed in a CLI tool, microservice, RAG pipeline, or Deno Deploy function — and you value simplicity over cluster-scale — MySSE is built for you.

Table of Contents

✨ Features

  • 100% In-Memory Vector Search — No external database, no disk I/O during queries. All vectors live in RAM as Float32Array for cache-friendly access.
  • HNSW Approximate Nearest Neighbor Index — Pure-TypeScript implementation of the 2016 Malkov & Yashunin paper. Automatic approximate nearest-neighbor search above 2 000 documents, exact brute-force below.
  • Adaptive Search Strategy — Brute-force (recall = 100%) when the index is small; HNSW (recall@10 ≥ 92%, 4–6× faster) when it grows — fully automatic, no config needed.
  • Pluggable Embedding Models — Ship with a deterministic hash-based embedder for zero-setup dev. Hot-swap to Ollama, Transformers.js, OpenAI, or any custom model at runtime via the EmbeddingSwap Power.
  • BM25 + Semantic Hybrid Search — Built-in Reciprocal Rank Fusion (RRF) blends keyword and dense retrieval for stronger results out of the box.
  • Powers Plugin System — Lifecycle hooks for caching, hybrid search, metadata filtering, and custom embedding models — without touching core code.
  • Pure TypeScript, Zero Dependencies — No native bindings, no WASM, no node_modules. Ships as a single JSR package.
  • Deno Secure-by-Default — Runs under Deno's permission system. No accidental file/network access.
  • ~1 000 Lines of Code — ~720 LOC core engine + ~310 LOC Powers. Read the whole thing in an afternoon.

🚀 Quick Start

Install from JSR

deno add jsr:@wxt/my-search-engine

Or add it manually to your import map:

// deno.json
{
  "imports": {
    "@wxt/my-search-engine": "jsr:@wxt/my-search-engine@^0.2.0"
  }
}

Use as a Library

import { SemanticEngine, HybridSearch } from "@wxt/my-search-engine";

const engine = SemanticEngine.getInstance();
engine.use(HybridSearch());

await engine.add([
  { id: "1", content: "Deno is a secure runtime for JS and TS" },
  { id: "2", content: "Fresh is a modern web framework for Deno" },
]);

const results = await engine.search("secure typescript runtime");
console.log(results);

Run the Demo Server

Prerequisites

Installation

# Clone the repository
git clone https://github.com/thewizster/MySSE.git
cd MySSE

# Start the development server
deno task dev

The server will start at http://localhost:8000 with a built-in search UI.

📖 API Reference

Add Documents

POST /api/add
Content-Type: application/json

# Single document
{"id": "doc1", "content": "Your document text", "metadata": {"source": "example"}}

# Multiple documents
[
  {"id": "doc1", "content": "First document text"},
  {"id": "doc2", "content": "Second document text"}
]

Example:

curl -X POST http://localhost:8000/api/add \
  -H "Content-Type: application/json" \
  -d '[
    {"id":"1", "content":"Deno is a secure runtime for JavaScript and TypeScript", "metadata":{"source":"docs"}},
    {"id":"2", "content":"Fresh is a modern web framework for Deno", "metadata":{"source":"docs"}},
    {"id":"3", "content":"Transformers.js runs ML models in the browser", "metadata":{"source":"docs"}}
  ]'

Search Documents

GET /api/search?q=<query>&k=<top_k>

Parameters:

  • q (required): The search query
  • k (optional): Number of results to return (default: 10, max: 100)

Example:

curl "http://localhost:8000/api/search?q=secure+typescript+runtime&k=5"

Search Results

Check Status

GET /api/status

Returns the current engine status and document count.

Clear Index

DELETE /api/clear

Removes all documents from the index.

🏗️ Architecture

MySSE/
├── lib/
│   ├── hnsw.ts               # HNSW approximate nearest-neighbor index (~210 LOC)
│   ├── semantic-engine.ts     # Core semantic search engine (~510 LOC)
│   └── powers/                # Extensibility plugins
│       ├── cache.ts           # QueryCache — search result caching
│       ├── embedding-swap.ts  # EmbeddingSwap — hot-swap embedding models
│       ├── hybrid-search.ts   # HybridSearch — BM25 + semantic RRF fusion
│       └── metadata-filter.ts # MetadataFilter — filter results by metadata
├── tests/
│   ├── hnsw_test.ts                   # HNSW unit tests (19 tests)
│   ├── semantic-engine_test.ts        # Engine unit tests (7 tests)
│   ├── semantic-engine-ann_test.ts    # ANN integration tests (8 tests)
│   └── powers_test.ts                # Powers unit tests (20 tests)
├── main.ts                   # HTTP server with routing & UI
├── deno.json                 # Deno configuration
└── README.md

🧠 How It Works

  1. Document Ingestion: Documents are embedded into 384-dimensional vectors
  2. Normalization: All vectors are pre-normalized to unit length
  3. Storage: Embeddings stored as Float32Array for cache-friendly access
  4. Indexing: Vectors are inserted into an HNSW graph (built incrementally on add())
  5. Search: Under 2000 docs → exact brute-force dot product; above → HNSW approximate search with O(log n) query time

HNSW Index

The HNSW implementation follows the original 2016 paper:

  • Level multiplier: mL = 1/ln(M) — ~94% of nodes on layer 0
  • Two-phase insert: greedy walk (ef = 1) on upper layers, then efConstruction-width search + bidirectional connect on lower layers
  • Neighbor shrinkage: connections pruned when exceeding M_max (2·M on layer 0)
  • Configurable: M, efConstruction, and efSearch exposed as optional parameters

Embedding Models

The engine uses a pluggable embedding interface:

  • SimpleEmbeddingModel (default): Hash-based embeddings for zero-dependency operation. Great for development and testing.
  • Ollama + nomic-embed-text (recommended): Run nomic-embed-text locally via Ollama for production-quality 768-dim embeddings. Scores jump to the 0.6–0.95 range with much stronger semantic matching.
  • TransformersJsEmbedding: Swap in @huggingface/transformers for in-process ML embeddings without a separate server.

Swap the model at runtime using the EmbeddingSwap Power — no restart needed:

# Install Ollama (https://ollama.com), then pull the model:
ollama pull nomic-embed-text
import { engine } from "./lib/semantic-engine.ts";
import { EmbeddingSwap } from "./lib/powers/embedding-swap.ts";

engine.use(EmbeddingSwap(async (texts) => {
  const res = await fetch("http://localhost:11434/api/embed", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ model: "nomic-embed-text", input: texts }),
  });
  const { embeddings } = await res.json();
  return embeddings.map((e: number[]) => new Float32Array(e));
}));

All indexing and searching will now use nomic-embed-text. See the Getting Started guide for a full walkthrough.

⚡ Powers (Plugin System)

Powers are MySSE's extensibility mechanism. A Power is a plain object with lifecycle hooks that the engine calls at key points in the search pipeline. Register a Power with engine.use(), remove it with engine.eject(). When no Powers are registered there is zero overhead.

How It Works

The engine runs each hook in registration order at the appropriate point:

Hook When it runs Typical use
beforeAdd Before documents are embedded Transform or enrich documents
afterAdd After documents are stored and indexed Build auxiliary indexes (e.g. BM25)
beforeSearch Before the core search runs Rewrite queries, return cached results
afterSearch After results are returned Re-rank, filter, or fuse results
embed Replaces the default embedding model Plug in a real ML model at runtime
onDelete After a document is deleted Clean up auxiliary state
onClear After the entire index is cleared Reset auxiliary state

Built-in Powers

MySSE ships with four ready-to-use Powers in lib/powers/:

QueryCache

Caches search results by query string. Repeated identical queries are served instantly from memory.

import { QueryCache } from "./lib/powers/cache.ts";

engine.use(QueryCache());                     // defaults: 100 entries, 60 s TTL
engine.use(QueryCache({ maxSize: 200, ttl: 30_000 }));  // custom settings

HybridSearch

Combines dense semantic retrieval with sparse BM25 keyword retrieval using Reciprocal Rank Fusion (RRF). This gives you the best of both worlds — semantic understanding and exact keyword matching.

import { HybridSearch } from "./lib/powers/hybrid-search.ts";

engine.use(HybridSearch());                   // 50/50 blend (recommended)
engine.use(HybridSearch({ alpha: 0.7 }));     // 70% semantic, 30% keyword
alpha value Behaviour
1.0 Pure semantic (dense only)
0.5 Equal blend (default)
0.0 Pure keyword (BM25 only)

MetadataFilter

Filters search results by document metadata using a predicate function.

import { MetadataFilter } from "./lib/powers/metadata-filter.ts";

// Only return published documents
engine.use(MetadataFilter((meta) => meta.published === true));

// Only return documents from a specific source
engine.use(MetadataFilter((meta) => meta.source === "docs"));

EmbeddingSwap

Hot-swaps the embedding model at runtime without restarting the engine or changing constructor options.

import { EmbeddingSwap } from "./lib/powers/embedding-swap.ts";

engine.use(EmbeddingSwap(async (texts) => {
  // Call your ML model, external API, etc.
  return texts.map(() => new Float32Array(384).fill(0.1));
}));

Combining Powers

Powers compose naturally. Register as many as you need — they run in order:

import { QueryCache } from "./lib/powers/cache.ts";
import { HybridSearch } from "./lib/powers/hybrid-search.ts";
import { MetadataFilter } from "./lib/powers/metadata-filter.ts";

engine.use(QueryCache({ ttl: 30_000 }));
engine.use(HybridSearch({ alpha: 0.6 }));
engine.use(MetadataFilter((meta) => meta.published === true));

With this setup, each search: checks the cache → runs semantic + BM25 fusion → filters by metadata → caches the result for next time.

Managing Powers at Runtime

// Register a Power
engine.use(QueryCache());

// List registered Powers
console.log(engine.powers);   // ["QueryCache"]

// Remove a Power by name
engine.eject("QueryCache");   // returns true if found and removed

// The /api/status endpoint also reports active Powers
curl http://localhost:8000/api/status
// { "status": "healthy", "documents": 42, "powers": ["QueryCache"], ... }

Writing a Custom Power

A Power is any object that satisfies the Power interface. At minimum it needs a name; every hook is optional — implement only what you need.

import type { Power } from "./lib/semantic-engine.ts";

const logger: Power = {
  name: "Logger",
  beforeSearch(ctx) {
    console.log(`Searching for: ${ctx.query}`);
    return ctx;
  },
  afterSearch(results, query) {
    console.log(`Found ${results.length} results for "${query}"`);
    return results;
  },
};

engine.use(logger);

Persistence (toJSON / fromJSON)

The engine supports exporting and importing its full state for persistence (e.g. to Deno KV or a JSON file). The HNSW index is rebuilt automatically on import, and afterAdd hooks fire so Powers like HybridSearch can rebuild auxiliary state.

// Export
const snapshot = engine.toJSON();
await Deno.writeTextFile("index.json", JSON.stringify(snapshot));

// Import
const data = JSON.parse(await Deno.readTextFile("index.json"));
await engine.fromJSON(data);

Performance

Benchmarked on a single thread with 10 000 documents and 384-dimensional vectors:

Metric Value
Brute-force query ~5 ms per query
HNSW query ~1.5 ms per query (4–6× faster)
HNSW recall@10 ≥ 92% (typically 95–97%)
Index build (10k docs) ~20 s (one-time, incremental on add)
Memory per vector ~1.5 KB (384 × 4 bytes + graph edges)

Tip: For real-world latency, pair MySSE with the QueryCache Power so repeated queries resolve in <0.1 ms.

🔧 Tasks

deno task dev      # Start development server with hot reload
deno task start    # Start production server
deno task check    # Run format, lint, and type checks
deno task test     # Run all 54 tests (engine, HNSW, Powers)

🎯 Use Cases

MySSE is a great fit anywhere you need fast, embedded semantic search without infrastructure overhead:

  • RAG (Retrieval-Augmented Generation) — Retrieve relevant context from your knowledge base before prompting an LLM. MySSE works as the retrieval layer in any RAG pipeline.
  • Help Center / FAQ Search — Let users ask questions in natural language and surface the right article even when wording doesn't match.
  • Chatbot Context Engine — Feed relevant documents to a conversational AI so it can answer grounded in your data.
  • CLI & Desktop Tools — Embed search directly in a Deno CLI app or Electron tool — no server required.
  • Note & Knowledge Base Search — Import Markdown or text files and find content by meaning, not just keywords.
  • Product Search — Let customers describe what they want ("lightweight running shoes") instead of navigating filter trees.
  • Deno Deploy Functions — Stand up a serverless semantic search endpoint with zero infrastructure.
  • Prototyping & Hackathons — Get semantic search working in minutes, then swap in a production model when you're ready.

🔮 Roadmap

  • Transformers.js Integration — Enable real in-process ML embeddings with @huggingface/transformers (or use the EmbeddingSwap Power today)
  • WebGPU Acceleration — Automatic GPU acceleration when available
  • Multi-Modal Search — Swap to CLIP model for image+text embeddings via EmbeddingSwap
  • Quantizationquantized: true for smaller model footprint
  • Persistence Adapters — Expand toJSON / fromJSON with streaming and Deno KV adapters
  • Community Powers — Build and share your own Powers — logging, rate-limiting, A/B testing, result enrichment, and more

📚 Getting Started Guide

New to semantic search? The Getting Started guide walks you through everything from installing Deno to adding documents, running queries, swapping embedding models, and using Powers — no ML background needed.

🤝 Contributing

Contributions are welcome! Whether it's bug fixes, new Powers, documentation improvements, or performance optimizations — open an issue or submit a pull request on GitHub.

# Clone and run the test suite
git clone https://github.com/thewizster/MySSE.git
cd MySSE
deno task test    # 54 tests across engine, HNSW, and Powers

📄 License

MIT License — see LICENSE for details.


Built with ❤️ in Texas!

Built with Deno · Published on JSR · Source on GitHub

If MySSE is useful to you, consider giving it a ⭐ on GitHub — it helps others find it.

About

My Semantic Search Engine - A fully in-RAM semantic search engine built with Deno in pure TypeScript — minimalist, elegant, blazing-fast, and built for 2026+.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors