Skip to main content
Technology & EngineeringSearch Services393 lines

Orama

"Orama: client-side/edge full-text search engine, TypeScript-native, schema, search with filters, vector search, runs in browser/Node/edge"

Quick Summary28 lines
Orama is a full-text and vector search engine written in TypeScript that runs everywhere JavaScript runs — browsers, Node.js, Deno, Bun, Cloudflare Workers, and edge functions. Its design principles are:

## Key Points

- **Zero infrastructure** — no servers to manage, no external services to call. The search index lives in the same runtime as your application.
- **TypeScript-native** — the API is fully typed. Schemas are defined in TypeScript and the search results preserve your document types.
- **Instant startup** — indices can be created from data in memory or deserialized from a snapshot in milliseconds.
- **Hybrid search** — full-text search and vector search in the same engine, with a single unified API.
- **Tiny footprint** — the core library is under 10 KB gzipped. It adds negligible overhead to a frontend bundle.
- **Use `insertMultiple` for bulk loading.** It is significantly faster than calling `insert` in a loop because it batches internal index updates.
- **Persist and restore indices** instead of rebuilding them on every page load. Use `save`/`load` with localStorage, IndexedDB, or a CDN-hosted JSON file.
- **Set `threshold` for typo tolerance control.** A value of 1.0 means exact matches only. Lower values (0.6-0.8) allow more typos but may reduce precision.
- **Use `enum` or `enum[]` for categorical data** rather than `string`. Enums are stored more efficiently and support exact filtering.
- **Pre-build the index at build time** for static sites. Run a build script that creates and serializes the index, then load it on the client.
- **Use `boost` to weight important fields.** Title and name fields should typically get a 2-3x boost over body/description fields.
- **Keep vector dimensions reasonable.** Larger vectors consume more memory. Use dimensionality reduction if running in the browser with constrained memory.

## Quick Example

```typescript
import { insertMultiple } from "@orama/orama";

async function bulkIndex(products: Product[]) {
  await insertMultiple(productDB, products, 500); // batch size of 500
}
```
skilldb get search-services-skills/OramaFull skill: 393 lines
Paste into your CLAUDE.md or agent config

Orama

Core Philosophy

Orama is a full-text and vector search engine written in TypeScript that runs everywhere JavaScript runs — browsers, Node.js, Deno, Bun, Cloudflare Workers, and edge functions. Its design principles are:

  • Zero infrastructure — no servers to manage, no external services to call. The search index lives in the same runtime as your application.
  • TypeScript-native — the API is fully typed. Schemas are defined in TypeScript and the search results preserve your document types.
  • Instant startup — indices can be created from data in memory or deserialized from a snapshot in milliseconds.
  • Hybrid search — full-text search and vector search in the same engine, with a single unified API.
  • Tiny footprint — the core library is under 10 KB gzipped. It adds negligible overhead to a frontend bundle.

Orama is ideal for client-side search (documentation sites, static sites, offline-capable apps), edge search (Cloudflare Workers, Vercel Edge Functions), and embedded search in Electron or React Native apps.

Setup

Install and create a typed index:

// npm install @orama/orama

import { create, insert, search, count } from "@orama/orama";

// Define your schema
const productDB = await create({
  schema: {
    name: "string",
    description: "string",
    price: "number",
    categories: "enum[]",
    rating: "number",
    inStock: "boolean",
    tags: "string[]",
  } as const,
});

// Insert documents
async function indexProducts(products: Product[]) {
  for (const product of products) {
    await insert(productDB, {
      name: product.name,
      description: product.description,
      price: product.price,
      categories: product.categories,
      rating: product.rating,
      inStock: product.inStock,
      tags: product.tags,
    });
  }

  console.log(`Indexed ${await count(productDB)} documents`);
}

Batch insert for better performance:

import { insertMultiple } from "@orama/orama";

async function bulkIndex(products: Product[]) {
  await insertMultiple(productDB, products, 500); // batch size of 500
}

Key Techniques

Full-Text Search with Filters

async function searchProducts(query: string, filters?: ProductFilters) {
  const results = await search(productDB, {
    term: query,
    properties: ["name", "description"],  // fields to search
    boost: { name: 2 },                   // name matches rank higher
    limit: 20,
    offset: 0,
    threshold: 0.8,                        // typo tolerance (0-1)
    where: filters ? buildWhereClause(filters) : undefined,
  });

  return {
    hits: results.hits.map((h) => ({
      document: h.document,
      score: h.score,
    })),
    count: results.count,
    elapsed: results.elapsed,
  };
}

function buildWhereClause(filters: ProductFilters) {
  const where: Record<string, any> = {};

  if (filters.minPrice !== undefined || filters.maxPrice !== undefined) {
    where.price = {
      ...(filters.minPrice !== undefined && { gte: filters.minPrice }),
      ...(filters.maxPrice !== undefined && { lte: filters.maxPrice }),
    };
  }
  if (filters.categories?.length) {
    where.categories = { containsAll: filters.categories };
  }
  if (filters.inStock !== undefined) {
    where.inStock = filters.inStock;
  }
  if (filters.minRating !== undefined) {
    where.rating = { gte: filters.minRating };
  }

  return where;
}

interface ProductFilters {
  minPrice?: number;
  maxPrice?: number;
  categories?: string[];
  inStock?: boolean;
  minRating?: number;
}

Faceted Search

async function facetedSearch(query: string) {
  const results = await search(productDB, {
    term: query,
    properties: ["name", "description"],
    facets: {
      categories: {
        limit: 10,
        order: "DESC",
      },
      price: {
        ranges: [
          { from: 0, to: 25 },
          { from: 25, to: 100 },
          { from: 100, to: 500 },
          { from: 500, to: Infinity },
        ],
      },
      rating: {
        ranges: [
          { from: 4, to: 5 },
          { from: 3, to: 4 },
          { from: 0, to: 3 },
        ],
      },
      inStock: {
        true: true,
        false: true,
      },
    },
  });

  return {
    hits: results.hits,
    facets: results.facets,
  };
}

Vector Search

import { create, insert, search } from "@orama/orama";

// Create a database with vector fields
const vectorDB = await create({
  schema: {
    title: "string",
    content: "string",
    embedding: "vector[1536]",  // OpenAI ada-002 dimensions
  } as const,
});

// Insert documents with embeddings
async function indexWithEmbeddings(docs: DocWithEmbedding[]) {
  for (const doc of docs) {
    await insert(vectorDB, {
      title: doc.title,
      content: doc.content,
      embedding: doc.embedding,  // number[] of length 1536
    });
  }
}

// Pure vector search
async function vectorSearch(queryEmbedding: number[]) {
  return search(vectorDB, {
    mode: "vector",
    vector: {
      value: queryEmbedding,
      property: "embedding",
    },
    limit: 10,
    similarity: 0.8,  // minimum similarity threshold
  });
}

// Hybrid search: full-text + vector combined
async function hybridSearch(query: string, queryEmbedding: number[]) {
  return search(vectorDB, {
    mode: "hybrid",
    term: query,
    vector: {
      value: queryEmbedding,
      property: "embedding",
    },
    properties: ["title", "content"],
    limit: 10,
  });
}

Serialization and Persistence

import { save, load } from "@orama/orama";

// Serialize the index to JSON
async function persistIndex() {
  const snapshot = await save(productDB);
  const json = JSON.stringify(snapshot);

  // Store anywhere: localStorage, file, KV store, S3
  localStorage.setItem("search-index", json);
  // or: await fs.writeFile("index.json", json);
  // or: await env.KV.put("search-index", json);
}

// Restore from snapshot
async function restoreIndex() {
  const json = localStorage.getItem("search-index");
  if (!json) return null;

  const snapshot = JSON.parse(json);
  const db = await create({
    schema: {
      name: "string",
      description: "string",
      price: "number",
      categories: "enum[]",
      rating: "number",
      inStock: "boolean",
      tags: "string[]",
    } as const,
  });

  await load(db, snapshot);
  return db;
}

React Integration

import React, { useState, useEffect, useMemo, useCallback } from "react";
import { create, insert, search, type Orama } from "@orama/orama";

function useOramaSearch(db: Orama | null) {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<any[]>([]);

  useEffect(() => {
    if (!db) return;

    const timer = setTimeout(async () => {
      if (!query.trim()) {
        setResults([]);
        return;
      }
      const res = await search(db, {
        term: query,
        properties: ["name", "description"],
        limit: 20,
        threshold: 0.8,
      });
      setResults(res.hits.map((h) => h.document));
    }, 150); // debounce

    return () => clearTimeout(timer);
  }, [db, query]);

  return { query, setQuery, results };
}

function SearchComponent({ products }: { products: Product[] }) {
  const [db, setDb] = useState<Orama | null>(null);

  useEffect(() => {
    (async () => {
      const database = await create({
        schema: {
          name: "string",
          description: "string",
          price: "number",
        } as const,
      });
      for (const p of products) {
        await insert(database, p);
      }
      setDb(database);
    })();
  }, [products]);

  const { query, setQuery, results } = useOramaSearch(db);

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search products..."
      />
      <ul>
        {results.map((r: any) => (
          <li key={r.name}>{r.name} — ${r.price}</li>
        ))}
      </ul>
    </div>
  );
}

Edge/Worker Deployment

// Cloudflare Worker example
import { create, insertMultiple, search, load, save } from "@orama/orama";

interface Env {
  SEARCH_INDEX: KVNamespace;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === "/search") {
      const query = url.searchParams.get("q") ?? "";
      const cached = await env.SEARCH_INDEX.get("index", "json");

      const db = await create({
        schema: { title: "string", body: "string", url: "string" } as const,
      });

      if (cached) {
        await load(db, cached as any);
      }

      const results = await search(db, {
        term: query,
        properties: ["title", "body"],
        limit: 10,
      });

      return Response.json({
        hits: results.hits.map((h) => h.document),
        count: results.count,
      });
    }

    return new Response("Not found", { status: 404 });
  },
};

Best Practices

  • Use insertMultiple for bulk loading. It is significantly faster than calling insert in a loop because it batches internal index updates.
  • Persist and restore indices instead of rebuilding them on every page load. Use save/load with localStorage, IndexedDB, or a CDN-hosted JSON file.
  • Set threshold for typo tolerance control. A value of 1.0 means exact matches only. Lower values (0.6-0.8) allow more typos but may reduce precision.
  • Use enum or enum[] for categorical data rather than string. Enums are stored more efficiently and support exact filtering.
  • Pre-build the index at build time for static sites. Run a build script that creates and serializes the index, then load it on the client.
  • Use boost to weight important fields. Title and name fields should typically get a 2-3x boost over body/description fields.
  • Keep vector dimensions reasonable. Larger vectors consume more memory. Use dimensionality reduction if running in the browser with constrained memory.

Anti-Patterns

  • Using Orama as a server-side database replacement. It is designed for search, not as a persistent data store. Keep a real database as the source of truth.
  • Rebuilding the index on every request in serverless. Serialize the index once and load from cache (KV, R2, Redis) on cold starts.
  • Indexing HTML or Markdown markup. Strip tags before indexing. Raw markup hurts relevance and produces ugly highlights.
  • Ignoring bundle size in the browser. While Orama itself is small, large datasets serialized as JSON can bloat initial page loads. Lazy-load the index after the page renders.
  • Using vector search without understanding dimensionality. Mismatched vector dimensions between indexed documents and queries cause runtime errors. Always validate consistency.
  • Skipping debounce on keystroke search. Searching on every keystroke without a debounce floods the engine with queries, especially with large indices.
  • Storing sensitive data in client-side indices. The entire index is in memory and inspectable via DevTools. Never index private or access-controlled data on the client.

Install this skill directly: skilldb add search-services-skills

Get CLI access →