harper-fabric-embeddings
Version:
Minimal llama.cpp embedding wrapper for Harper Fabric. Talks directly to the native addon — no build tools, no CLI, no chat wrappers.
207 lines • 9.83 kB
TypeScript
/**
* Embedding engine internals.
*
* `EmbeddingEngine` owns one GGUF model + llama.cpp context and serializes
* embed calls against it. Multiple engines can coexist in one process — one
* per registered Harper model entry — so the native addon binding is shared
* through a process-resident registry keyed by addon path (dlopen of the same
* path returns the same handle; llama.cpp keeps all state in heap objects, so
* engines stay independent through the shared native code).
*/
import { type PoolingName } from './gguf.js';
/**
* Per-inputType prompt templates, declared as data on a model entry (built-in
* registry or `templates` in engine/config options) instead of detected by
* model-name heuristics in code. See issue #4.
*
* Placeholders are `{name}` tokens: `{text}` is the input text (always
* provided); `{task}` comes from the embed call's `task` option, falling back
* to `defaults.task`; any other placeholder must have a value in `defaults`.
* Literal braces are escaped as `{{` / `}}`. Interpolation is single-pass —
* placeholder values are never re-scanned for placeholders.
*
* A missing template for an inputType means passthrough, and an omitted
* `inputType` is ALWAYS passthrough regardless of templates — that is the
* compatibility contract that keeps vectors from older versions comparable.
*/
export interface EmbedTemplates {
/** Template applied when `inputType: 'document'`. */
document?: string;
/** Template applied when `inputType: 'query'`. */
query?: string;
/** Fallback values for non-`{text}` placeholders (e.g. `task`, `title`). */
defaults?: Record<string, string>;
}
export interface EngineOptions {
/** Absolute path to a .gguf model file. */
modelPath?: string;
/** Directory containing (or to download to) model files. */
modelsDir?: string;
/** Model name from the built-in registry. */
modelName?: string;
/** Token context window size. */
contextSize?: number;
/**
* Batch processing size. In node-llama-cpp this sets both `n_batch` AND
* `n_ubatch` on the llama.cpp context. llama.cpp's encoder asserts
* `n_ubatch >= n_tokens` for every input — inputs that tokenize above
* `batchSize` trigger `GGML_ASSERT` → `ggml_abort`, killing the host
* process. Defaults to `contextSize` so the full context window is
* usable out of the box.
*/
batchSize?: number;
/** CPU threads for inference. */
threads?: number;
/** Layers to offload to GPU (0 = CPU only). */
gpuLayers?: number;
/** Override path to llama-addon.node. */
addonPath?: string;
/**
* Prompt templates for this model. Overrides the built-in registry entry's
* templates; validated at construction (registration time). Without this —
* and without registry templates — template-less models fall back to the
* legacy nomic name-prefix heuristic.
*/
templates?: EmbedTemplates;
/**
* Expected pooling for this model (`'none' | 'mean' | 'cls' | 'last' |
* 'rank'`). Verification, not override: the native addon exposes no pooling
* option, so the llama.cpp context always uses the model's own
* `<arch>.pooling_type` metadata. Declaring the expectation makes init fail
* loudly when the GGUF omits or contradicts it — the alternative is a
* metadata-less conversion silently mean-pooling a last-token model
* (issue #12). Omitted = accept whatever the model resolves to.
*/
pooling?: PoolingName;
}
export interface EmbedManyOptions {
/**
* For models that distinguish document-vs-query embeddings. Applies the
* model's template for that side (or the legacy nomic prefix for
* template-less models). Omitted = passthrough, always.
*/
inputType?: 'document' | 'query';
/**
* Free-text task instruction for models whose templates use `{task}`
* (instruct-style embedders). Overrides the entry's `templates.defaults.task`.
*/
task?: string;
/** Best-effort cancellation — checked between inputs, not mid-decode. */
signal?: AbortSignal;
}
export interface EmbedManyResult {
/** One L2-normalized vector per input, in input order. */
vectors: Float32Array[];
/** Total tokens decoded across all inputs (including BOS/EOS). */
tokens: number;
}
/**
* Native embedding context handle. Exported because `decodeAndEmbed` — public
* so tests can exercise it against a fake — takes one as a parameter;
* `declaration: true` requires every type reachable from an exported
* signature to itself be exported.
*/
export interface LlamaContext {
init(): Promise<boolean>;
dispose(): Promise<void>;
initBatch(size: number): void;
addToBatch(seq: number, pos: number, tokens: Uint32Array, logitIndexes: Uint32Array): void;
decodeBatch(): Promise<void>;
getEmbedding(tokenCount: number): Float32Array;
/**
* Evict every KV-cache cell for a sequence (`llama_memory_seq_rm(seq, -1, -1)`
* under the hood). Synchronous; throws if the native eviction fails. A no-op
* on a sequence with no cached cells (e.g. a freshly created context).
*/
disposeSequence(seq: number): void;
}
/**
* One GGUF model + llama.cpp embedding context.
*
* - Construction validates configuration synchronously (missing model source,
* unknown model name) so misconfiguration fails at registration, not first use.
* - Heavy work (download, addon + model load) happens in `ensureReady()`, which
* is lazy, shared across concurrent callers, and retryable after failure.
* - `embedMany()` serializes against a per-engine queue — the llama.cpp context
* is not safe for concurrent use.
*/
export declare class EmbeddingEngine {
#private;
constructor(options: EngineOptions);
/** Model name (or model file basename) — used for backend naming and prefix detection. */
get modelIdentity(): string;
/**
* Kick off (or join) initialization. Lazy and retryable: a failed attempt
* resets so the next call tries again (e.g. a transient download failure).
*/
ensureReady(): Promise<void>;
/**
* Generate one L2-normalized vector per input, in input order.
*
* Serialized against the engine's queue; init is awaited lazily on first
* call. `opts.signal` is checked between inputs (best-effort — a decode in
* flight can't be interrupted).
*/
embedMany(texts: string[], opts?: EmbedManyOptions): Promise<EmbedManyResult>;
/** Get the embedding vector dimensionality. */
dimensions(): number;
/**
* Clean up native resources.
*
* Drains the embed queue before touching native handles — disposing the
* llama.cpp context while a decodeBatch is executing is a use-after-free
* that can kill the host process. Embeds accepted before this call complete
* (when the engine is initialized); embeds that would need a fresh init
* during disposal, and any submitted after, reject with a disposed error.
*/
dispose(): Promise<void>;
}
/**
* Decode one token sequence against a single-sequence llama.cpp context
* (`sequences: 1` — see `#doInit`) and return its L2-normalized embedding.
*
* Always decodes at `seq=0, pos=0`: there is exactly one KV-cache sequence
* slot on the context, reused across every call. `disposeSequence(0)` evicts
* whatever cache cells the *previous* call on this context left behind
* before writing new cells at pos 0 — without it, a second decode on the
* same context sees inconsistent KV-cache/position state at pos 0 and
* `llama_decode` hard-aborts the host process (a native `GGML_ASSERT`, not a
* catchable JS error). A no-op on a fresh context (issue #8).
*
* A free function (not a private method) so it can be unit tested against a
* fake `LlamaContext` double — the real native context can't be constructed
* without a loaded GGUF model.
*/
export declare function decodeAndEmbed(context: LlamaContext, input: Uint32Array): Promise<{
vector: Float32Array;
tokens: number;
}>;
/**
* Resolve the templates an engine will use: explicit `options.templates` wins;
* a registry model falls back to its entry's templates; an explicit `modelPath`
* with no explicit templates gets none (the legacy name-prefix heuristic still
* applies at embed time). Exported for tests — the models-backend production
* path constructs via `modelName` and must resolve the registry branch.
*/
export declare function resolveEngineTemplates(options: EngineOptions): EmbedTemplates | undefined;
/**
* Validate templates at construction (registration) time, so misconfiguration
* fails at Harper boot instead of on the first embed call. Unrecognized
* top-level keys are rejected (a typo'd side like `documnet:` would otherwise
* silently fall back to unprefixed embeds); every placeholder must be `{text}`,
* `{task}` (call-suppliable), or covered by `defaults`; any other `{`/`}` must
* be escaped as `{{` / `}}`.
*/
export declare function validateTemplates(templates: EmbedTemplates): void;
/** Single-pass interpolation. `vars` must cover every placeholder (validated at registration for all but a default-less `{task}`). */
export declare function renderTemplate(template: string, vars: Record<string, string>): string;
/**
* Download a model from Hugging Face.
*
* Cross-worker coordination: exclusive creation of `<file>.downloading` elects
* one downloader; the rest poll. A lock whose owner died is reclaimed after
* `LOCK_STALE_MS`, and a waiter whose winner failed (lock vanished, no final
* file) retakes the lock and retries rather than timing out.
*/
export declare function downloadModel(dir: string, modelName?: string): Promise<string>;
//# sourceMappingURL=engine.d.ts.map