# Getting Started with @coda/search

Build a working search engine over your own data in three steps: define your document type, implement two interfaces, and wire them into `SearchEngine`.

## Prerequisites

```jsonc
// package.json
{ "dependencies": { "@coda/search": "workspace:*" } }
```

You'll need an `EmbeddingProvider` (or pass `null` for keyword-only mode) and a `SnapshotPersistence` implementation (or use the in-memory stub below for development).

## Step 1: Define your document type

```ts
/** Raw item from your data source. */
interface RawProduct {
  sku: string;
  name: string;
  category: string;
  description: string;
}

/** Searchable document — can be the same as the raw type or a transformed version. */
type ProductDoc = RawProduct;
```

## Step 2: Implement SchemaFetcher and DocumentTransformer

```ts
import type {
  SchemaFetcher,
  FetchResult,
  SchemaDiff,
  DocumentTransformer,
} from "@coda/search";
import type { KeywordField } from "@coda/search";
import { tokenize } from "@coda/search";

/** Fetches your data. Called once on init(), then diff() on each refresh(). */
class ProductFetcher implements SchemaFetcher<RawProduct> {
  readonly name = "products";

  async fetch(): Promise<FetchResult<RawProduct>> {
    const items: RawProduct[] = await loadProductsFromDb(); // your data source
    return { items, hash: String(items.length) };
  }

  async diff(): Promise<SchemaDiff<RawProduct> | null> {
    return null; // return null = no changes; return { added, changed, removed } for incremental
  }

  async teardown(): Promise<void> {}
}

/** Maps raw items to searchable documents. */
class ProductTransformer implements DocumentTransformer<
  RawProduct,
  ProductDoc
> {
  adapt(raw: RawProduct): ProductDoc {
    return raw;
  }

  getId(doc: ProductDoc): string {
    return doc.sku;
  }

  /** Full text sent to the embedding model. */
  buildDocument(doc: ProductDoc): string {
    return `${doc.name} ${doc.category} ${doc.description}`;
  }

  /** Pre-tokenized keyword fields for BM25. */
  getKeywords(doc: ProductDoc): KeywordField[] {
    return [
      { tokens: tokenize(doc.name), boost: 2 },
      { tokens: tokenize(doc.description) },
    ];
  }
}
```

## Step 3: Wire into SearchEngine

```ts
import {
  SearchEngine,
  type SearchEngineConfig,
  type SnapshotPersistence,
  type IndexSnapshot,
} from "@coda/search";

// Minimal in-memory snapshot store (use S3 in production)
class MemorySnapshotStore implements SnapshotPersistence {
  private data: IndexSnapshot<unknown> | null = null;

  async load<T>(): Promise<IndexSnapshot<T> | null> {
    return this.data as IndexSnapshot<T> | null;
  }

  async save<T>(snapshot: IndexSnapshot<T>): Promise<void> {
    this.data = snapshot as IndexSnapshot<unknown>;
  }
}

const engine = new SearchEngine<RawProduct, ProductDoc>({
  fetcher: new ProductFetcher(),
  transformer: new ProductTransformer(),
  snapshotStore: new MemorySnapshotStore(),
  modelId: "keyword-only", // arbitrary string; matters when vectors are cached
  factory: {
    embeddingProvider: null, // null = keyword-only (no vectors)
    engineName: "products",
  },
});

await engine.init();

const { results, scores } = await engine.search("wireless headphones", {
  limit: 10,
});
for (let i = 0; i < results.length; i++) {
  console.log(`${scores[i]?.toFixed(3)}  ${results[i]!.name}`);
}

await engine.destroy();
```

That's it — a working keyword search engine in ~60 lines.

## Adding vector search

Replace `embeddingProvider: null` with a real provider to enable hybrid BM25 + HNSW:

```ts
import type { EmbeddingProvider } from "@coda/search";

class MyEmbedder implements EmbeddingProvider {
  readonly dimensions = 384;

  async embed(texts: Iterable<string>): Promise<Float32Array[]> {
    // Call your embedding model (ONNX, OpenAI, etc.)
    return [...texts].map((t) => embedWithModel(t));
  }

  async embedQuery(texts: Iterable<string>): Promise<Float32Array[]> {
    return this.embed(texts); // or use query-specific prefixes
  }

  async initialize(): Promise<void> {}
  async dispose(): Promise<void> {}
}

// Then in your config:
factory: {
  embeddingProvider: new MyEmbedder(),
  engineName: "products",
}
```

The engine will automatically use two-phase initialization: keyword search is available immediately while vectors embed in the background.

## Adding a domain glossary

Glossary entries boost results when domain-specific terms match:

```ts
import type { GlossaryProvider, GlossaryEntry } from "@coda/search";

class ProductGlossary implements GlossaryProvider {
  getGlossaryEntries(): GlossaryEntry[] {
    return [
      {
        terms: ["headphones", "earbuds", "earphones"],
        targets: ["SKU-HP-100", "SKU-HP-200", "SKU-EB-50"],
        context: "Audio listening devices",
      },
      {
        terms: ["laptop", "notebook"],
        targets: ["SKU-LP-PRO", "SKU-LP-AIR"],
      },
    ];
  }
}

// Add to factory config:
factory: {
  embeddingProvider: new MyEmbedder(),
  glossaryProvider: new ProductGlossary(),
  engineName: "products",
}
```

## Adding a relationship graph

A `GraphBuilder` creates a typed graph that powers degree and proximity ranking signals:

```ts
import type { GraphBuilder } from "@coda/search";
import { LabeledGraph } from "@coda/search";

class ProductGraphBuilder implements GraphBuilder<ProductDoc> {
  buildGraph(docs: ProductDoc[]): LabeledGraph<string> {
    const g = new LabeledGraph<string>();

    for (const doc of docs) {
      g.addNode({ key: doc.sku, kind: "product", data: doc.sku });
    }

    // Add edges between products in the same category
    const byCategory = Map.groupBy(docs, (d) => d.category);
    for (const [, group] of byCategory) {
      for (let i = 0; i < group.length; i++) {
        for (let j = i + 1; j < group.length; j++) {
          g.addEdge({
            from: group[i]!.sku,
            to: group[j]!.sku,
            relation: "same_category",
          });
        }
      }
    }

    return g;
  }
}

// Add to factory config:
factory: {
  embeddingProvider: new MyEmbedder(),
  glossaryProvider: new ProductGlossary(),
  graphBuilder: new ProductGraphBuilder(),
  engineName: "products",
}
```

Search results will now include graph-augmented `related` items and `joinPaths`.

## Incremental updates

Implement `diff()` on your fetcher to support incremental refresh without full re-indexing:

```ts
async diff(): Promise<SchemaDiff<RawProduct> | null> {
  const changes = await getChangedProductsSinceLastPoll();
  if (!changes) return null; // no changes

  return {
    added: changes.newProducts,
    changed: changes.updatedProducts,
    removed: changes.deletedSkus, // string[] of IDs
  };
}
```

Then call `engine.refresh()` on a timer or webhook.

## Observability

Subscribe to engine events for logging, metrics, or debugging:

```ts
import { InMemoryEventBus, SearchEventType } from "@coda/search";

const eventBus = new InMemoryEventBus();

eventBus.on(SearchEventType.POLL_COMPLETED, (e) => {
  console.log(`refresh completed in ${e.data.durationMs}ms`);
});

eventBus.on(SearchEventType.EMBED_COMPLETED, (e) => {
  console.log(`embedded ${e.data.vectorCount} vectors in ${e.data.embedDurationMs}ms`);
});

// Pass to factory config:
factory: {
  embeddingProvider: new MyEmbedder(),
  eventBus,
  engineName: "products",
}
```

## Next steps

- [Concepts](concepts.md) — understand BM25, HNSW, RRF, and glossary matching
- [Pipeline Architecture](pipeline.md) — how expanders, stages, and signals compose
- [Tuning Guide](tuning.md) — adjust parameters for your corpus
- [Extending](extending.md) — build custom stages, signals, and scorers
- [Engine Lifecycle](engine.md) — init, refresh, search, destroy in detail
