nx-semantic-matcher
v1.0.1
Published
Tiered text matching pipeline (Exact, Fuzzy, Embedding, LLM)
Maintainers
Readme
nx-semantic-matcher
Tiered Text Matching Pipeline for TypeScript/Node.js
Match an input string against a list of candidate items using a 4-tier pipeline — each tier is progressively smarter and more expensive. The pipeline stops as soon as a confident match is found.
| Tier | Name | Speed | Cost | What it handles | |------|------|-------|------|-----------------| | T1 | Exact / Normalized | ~0 ms | Free | Case, whitespace, punctuation differences | | T2 | Fuzzy (Fuse.js) | ~1–5 ms | Free | Typos, minor reordering, character transpositions | | T3 | Semantic Embeddings | ~10–80 ms | Free (local) / ~$0.02/M tokens (OpenAI) | Synonyms, paraphrasing, intent equivalence | | T4 | LLM Classification | ~500–3000 ms | Pay-per-call | Ambiguous edge cases — last resort |
Install
npm install nx-semantic-matcherInstall optional providers for the tiers you need:
npm install @xenova/transformers # Tier 3 – local embeddings (no API key, ~30 MB)Tier 4 uses nx-ai-api (included) for OpenRouter, llama.cpp, or Transformers.js — no extra install for remote LLMs.
Quick Start
import { SemanticMatcher } from "nx-semantic-matcher";
const questions = [
{ id: "q1", text: "How do I reset my password?" },
{ id: "q2", text: "What is your refund policy?" },
{ id: "q3", text: "How do I contact support?" },
];
// Local embeddings — no API key needed, downloads ~30 MB on first run
const matcher = new SemanticMatcher(questions, {
embedding: { provider: "local" },
});
const result = await matcher.match("Steps to change my password");
if (result.found) {
console.log(`Matched: ${result.id} via Tier ${result.tier} (score ${result.score})`);
// → Matched: q1 via Tier 3 (score 0.87)
} else {
console.log(`Not found: ${result.reason}`);
}Configuration
import { SemanticMatcher, MatcherConfig } from "nx-semantic-matcher";
const config: MatcherConfig = {
// Tier toggles and thresholds
tiers: {
t1: { enabled: true },
t2: { enabled: true, threshold: 0.72 },
t3: { enabled: true, threshold: 0.72, lazy: false },
t4: { enabled: false, threshold: "medium", maxCandidatesInPrompt: 100, timeout: 10000 },
},
// Embedding provider for T3
embedding: {
provider: "local", // "local" | "openai" | EmbeddingProvider
// model: "Xenova/all-MiniLM-L6-v2",
// apiKey: "sk-...",
},
// Tier 4: nx-ai-api (OpenRouter, llama-cpp, or Transformers.js)
ai: {
backend: "openrouter", // "openrouter" | "llama-cpp" | "transformersjs"
model: "openai/gpt-4o",
// apiKey from env: OPENROUTER_API_KEY or OPEN_ROUTER_KEY
// Or inject a custom provider: tiers.t4.provider = myLLMProvider
},
debug: false, // log tier decisions to stderr
};Configuration Quick Reference
| Config key | Default | Description |
|---|---|---|
| tiers.t1.enabled | true | Enable Tier 1 exact/normalized matching |
| tiers.t2.enabled | true | Enable Tier 2 Fuse.js fuzzy matching |
| tiers.t2.threshold | 0.72 | Min confidence for T2 match (0–1) |
| tiers.t3.enabled | true | Enable Tier 3 embedding similarity |
| tiers.t3.threshold | 0.72 | Min cosine similarity for T3 match |
| tiers.t3.lazy | false | Defer index build to first query |
| tiers.t4.enabled | false | Enable Tier 4 LLM classification |
| tiers.t4.threshold | "medium" | Min LLM confidence: "high" or "medium" |
| tiers.t4.maxCandidatesInPrompt | 100 | Max items sent to LLM |
| tiers.t4.timeout | 10000 | LLM timeout in ms |
| embedding.provider | — | "local" | EmbeddingProvider |
| tiers.t3.provider | — | Custom EmbeddingProvider |
| tiers.t4.provider | — | Custom LLMProvider (overrides ai when set) |
| ai | — | nx-ai-api config: backend, model, etc. (see below) |
| debug | false | Log tier decisions to stderr |
API
new SemanticMatcher(items, config?)
Creates a new matcher. Builds the T3 embedding index eagerly unless tiers.t3.lazy = true.
const matcher = new SemanticMatcher(
[{ id: "1", text: "..." }],
{ embedding: { provider: "local" } }
);matcher.match(query)
Matches a query against the current item list.
const result = await matcher.match("my query");
// result: MatchFound | MatchNotFoundmatcher.setItems(items)
Replaces the candidate list and rebuilds the embedding index.
matcher.rebuildIndex()
Force-rebuilds the T3 index (e.g. after external mutation).
matcher.dispose()
Releases model handles and clears in-memory vectors.
SemanticMatcher.matchOnce(query, items, config?)
Static convenience method — creates, matches, and disposes in one call. Avoid in hot loops.
Usage Examples
Fuzzy-only (no AI dependencies)
const matcher = new SemanticMatcher(items, {
tiers: {
t3: { enabled: false },
t4: { enabled: false },
},
});
// T1 + T2 only — zero model downloads, synchronous-equivalentWith LLM fallback (OpenRouter / nx-ai-api)
const matcher = new SemanticMatcher(items, {
embedding: { provider: "local" },
tiers: { t4: { enabled: true, threshold: "high" } },
ai: {
backend: "openrouter",
model: "openai/gpt-4o",
// Uses OPENROUTER_API_KEY or OPEN_ROUTER_KEY from env
},
});Tier 4 with nx-ai-api (OpenRouter, llama-cpp, Transformers.js)
// Remote: OpenRouter (any model)
const matcher = new SemanticMatcher(items, {
tiers: { t4: { enabled: true } },
ai: { backend: "openrouter", model: "openai/gpt-4o" },
});
// Local: llama.cpp
const matcher = new SemanticMatcher(items, {
tiers: { t4: { enabled: true } },
ai: { backend: "llama-cpp", modelPath: "/path/to/model.gguf" },
});
// Local: Transformers.js
const matcher = new SemanticMatcher(items, {
tiers: { t4: { enabled: true } },
ai: { backend: "transformersjs", modelId: "Xenova/Llama-3-8B" },
});Custom embedding provider
import type { EmbeddingProvider } from "nx-semantic-matcher";
class MyProvider implements EmbeddingProvider {
async embed(text: string): Promise<Float32Array> { /* ... */ }
async embedBatch(texts: string[]): Promise<Float32Array[]> { /* ... */ }
}
const matcher = new SemanticMatcher(items, {
tiers: { t3: { provider: new MyProvider() } },
});Provider Interfaces
EmbeddingProvider
interface EmbeddingProvider {
embed(text: string): Promise<Float32Array>;
embedBatch?(texts: string[]): Promise<Float32Array[]>;
init?(): Promise<void>;
dispose?(): Promise<void>;
}LLMProvider
interface LLMProvider {
classify(query: string, candidates: MatchItem[]): Promise<LLMClassification>;
}Result Types
type MatchResult = MatchFound | MatchNotFound;
interface MatchFound {
found: true;
id: string; // matched item id
text: string; // matched item text
score: number; // confidence [0, 1]
tier: 1 | 2 | 3 | 4; // which tier matched
tierName: string; // human-readable tier label
durationMs: number; // total pipeline duration
reasoning?: string; // populated only for tier 4
}
interface MatchNotFound {
found: false;
durationMs: number;
reason: string;
}Error Handling
nx-semantic-matcher never throws for a failed match — it returns MatchNotFound. It does throw for misconfiguration:
| Error | Thrown when |
|---|---|
| NxConfigError | Invalid config at construction time |
| NxProviderError | Provider init fails (bad API key, missing package) |
| NxIndexError | Embedding index build or query fails |
Tier 4 LLM errors (timeout, API 5xx, JSON parse failure) are caught silently — they log a warning (if debug: true) and the pipeline returns NOT_FOUND.
Performance
- T1 + T2:
< 5 msfor up to 100,000 items. - T3 index build: ~50 ms per 1,000 items with the local model — run eagerly at startup.
- T3 query: O(n·d) cosine scan. For n=10,000, d=384: ~15 ms on a modern CPU.
- For n > 50,000 or sub-10 ms T3 requirements: plug in a vector database (pgvector, Qdrant) via a custom
EmbeddingProvider.
License
MIT
