embedding

package
v1.0.0-beta.158 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 22, 2026 License: MIT Imports: 27 Imported by: 0

README

Embedding Package

Vector embedding generation and semantic similarity utilities for the SemStreams graph system.

Purpose

This package provides interfaces and implementations for generating vector embeddings from text, enabling semantic similarity search across entities. Supports multiple embedding providers (HTTP APIs, BM25) with content-addressed caching for deduplication and performance.

Key Interfaces

Embedder

Primary interface for generating vector embeddings from text:

type Embedder interface {
    // Generate creates embeddings for the given texts (batch operation)
    Generate(ctx context.Context, texts []string) ([][]float32, error)

    // Dimensions returns the dimensionality of embedding vectors
    Dimensions() int

    // Model returns the model identifier (for logging/debugging)
    Model() string

    // Close releases any resources held by the embedder
    Close() error
}

Design note: All providers natively support batch operations following OpenAI API patterns. For single text embedding, pass a slice with one element.

Cache

Content-addressed caching for embeddings to avoid redundant computation:

type Cache interface {
    // Get retrieves cached embedding by content hash
    Get(ctx context.Context, contentHash string) ([]float32, error)

    // Put stores embedding with content hash as key
    Put(ctx context.Context, contentHash string, embedding []float32) error
}

Use cryptographic hashes (e.g., SHA-256) of text content as cache keys for deduplication.

Available Implementations

HTTP Embedder

Connects to remote embedding services via HTTP:

embedder, err := embedding.NewHTTPEmbedder(embedding.HTTPConfig{
    BaseURL: "http://embedding-service:8080",
    Model:   "all-MiniLM-L6-v2",
})
if err != nil {
    return fmt.Errorf("failed to create embedder: %w", err)
}
defer embedder.Close()

Typically used with models like all-MiniLM-L6-v2 (384 dimensions) or similar sentence transformers.

BM25 Embedder

Statistical keyword-based embeddings for baseline similarity without external dependencies:

embedder := embedding.NewBM25Embedder(corpus)
defer embedder.Close()

Useful for development, testing, or scenarios where ML-based embeddings are unavailable.

Usage Example

// Initialize embedder
embedder, err := embedding.NewHTTPEmbedder(embedding.HTTPConfig{
    BaseURL: "http://embedding-service:8080",
    Model:   "all-MiniLM-L6-v2",
})
if err != nil {
    return fmt.Errorf("failed to create embedder: %w", err)
}
defer embedder.Close()

// Generate embeddings (batch operation)
texts := []string{
    "drone navigation system failure",
    "autonomous vehicle sensor malfunction",
    "robotic arm calibration error",
}

embeddings, err := embedder.Generate(ctx, texts)
if err != nil {
    return fmt.Errorf("embedding generation failed: %w", err)
}

// Process results
for i, emb := range embeddings {
    log.Printf("Text %d: %d-dimensional embedding", i, len(emb))
}

// Use with cache for repeated queries
hash := sha256.Sum256([]byte(texts[0]))
hashStr := hex.EncodeToString(hash[:])

// Try cache first
cached, err := cache.Get(ctx, hashStr)
if err == nil {
    embeddings[0] = cached // Use cached version
} else {
    // Store for next time
    cache.Put(ctx, hashStr, embeddings[0])
}

Similarity Functions

Cosine Similarity

Primary similarity metric for comparing embedding vectors:

func CosineSimilarity(a, b []float32) float64

Returns value between -1 and 1:

  • 1: Vectors are identical (maximum similarity)
  • 0: Vectors are orthogonal (unrelated)
  • -1: Vectors are opposite (maximum dissimilarity)

Formula: cos(θ) = (A · B) / (||A|| × ||B||)

Example:

sim := embedding.CosineSimilarity(embedding1, embedding2)
if sim > 0.8 {
    log.Println("High semantic similarity detected")
}

Integration with IndexManager

The processor/graph/indexmanager/ package uses embeddings for semantic search:

// Index entity content
text := extractTextFromEntity(entity)
embeddings, _ := embedder.Generate(ctx, []string{text})
indexManager.IndexEmbedding(ctx, entity.ID, embeddings[0])

// Semantic search
queryEmbeddings, _ := embedder.Generate(ctx, []string{query})
results := indexManager.SearchBySimilarity(ctx, queryEmbeddings[0], threshold)

Performance Considerations

Batch Operations

Always use batch operations when embedding multiple texts:

// Good: Single batch call
embeddings, _ := embedder.Generate(ctx, texts)

// Bad: Multiple single calls (slower, more overhead)
for _, text := range texts {
    emb, _ := embedder.Generate(ctx, []string{text})
}
Caching Strategy

Use content hashing for cache keys to enable:

  • Deduplication: Same text → same cache key
  • Fast lookups: O(1) cache retrieval
  • No false hits: Cryptographic hash prevents collisions
hash := sha256.Sum256([]byte(text))
key := hex.EncodeToString(hash[:])
Resource Management

Always close embedders when done to release resources:

embedder, err := embedding.NewHTTPEmbedder(embedding.HTTPConfig{
    BaseURL: "http://embedding-service:8080",
    Model:   "all-MiniLM-L6-v2",
})
if err != nil {
    return fmt.Errorf("failed to create embedder: %w", err)
}
defer embedder.Close() // Essential for cleanup

For HTTP providers this is typically a no-op, but for local ONNX models this releases GPU/CPU resources.

Package Location

Previously located at pkg/embedding/, this package was moved to processor/graph/embedding/ per ADR-PACKAGE-RESPONSIBILITIES-CONSOLIDATION to clarify that embedding generation is graph processing functionality, not a standalone reusable library.

All graph processing capabilities now live under processor/graph/:

  • processor/graph/ - Main processor and mutations
  • processor/graph/querymanager/ - Query execution
  • processor/graph/indexmanager/ - Indexing operations (uses this package)
  • processor/graph/clustering/ - Community detection
  • processor/graph/embedding/ - Vector embeddings (this package)

Documentation

Overview

Package embedding provides vector embedding generation and caching for semantic search in the knowledge graph.

Overview

The embedding package generates dense vector representations of text content, enabling semantic similarity search across entities. It supports multiple embedding strategies: neural embeddings via HTTP APIs (TEI, OpenAI, LocalAI) and pure-Go lexical embeddings using BM25 as a fallback.

Embeddings are cached using content-addressed storage (SHA-256 hashes) to enable deduplication across entities with identical content. An async worker monitors pending embedding requests and processes them in the background.

Architecture

                     ┌─────────────────────────────────────────┐
                     │              Embedder                   │
                     │  (HTTPEmbedder or BM25Embedder)         │
                     └─────────────────────────────────────────┘
                                       ↓
┌────────────────────────────────────────────────────────────────┐
│                        Worker                                  │
│ Watches EMBEDDING_INDEX KV for status="pending" records        │
├──────────────────────────────┬─────────────────────────────────┤
│ Check dedup cache            │ Generate new embedding          │
│ (content hash lookup)        │ (call embedder)                 │
└──────────────────────────────┴─────────────────────────────────┘
                                       ↓
┌────────────────────────────────────────────────────────────────┐
│                     NATS KV Storage                            │
├───────────────────────────────┬────────────────────────────────┤
│  EMBEDDING_INDEX              │  EMBEDDING_DEDUP               │
│  entityID → Record            │  contentHash → DedupRecord     │
│  (vector, status, metadata)   │  (vector, entity IDs)          │
└───────────────────────────────┴────────────────────────────────┘

Usage

Configure and use the HTTP embedder with an external service:

embedder, err := embedding.NewHTTPEmbedder(embedding.HTTPConfig{
    BaseURL: "http://tei:8082",
    Model:   "all-MiniLM-L6-v2",
    Cache:   embedding.NewNATSCache(cacheBucket),
})

// Generate embeddings for batch of texts
vectors, err := embedder.Generate(ctx, []string{
    "autonomous drone navigation system",
    "ground control station for UAV fleet",
})

Use BM25 embedder as fallback when neural services unavailable:

embedder := embedding.NewBM25Embedder(embedding.BM25Config{
    Dimensions: 384,
    K1:         1.5,  // Term frequency saturation
    B:          0.75, // Length normalization
})

Start the async worker to process pending embeddings:

worker := embedding.NewWorker(storage, embedder, indexBucket, logger).
    WithWorkers(5).
    WithContentStore(objectStore). // For ContentStorable entities
    WithOnGenerated(func(entityID string, vector []float32) {
        // Update vector index cache
    })

worker.Start(ctx)
defer worker.Stop()

Embedders

HTTPEmbedder (HTTPEmbedder):

Calls OpenAI-compatible embedding APIs. Compatible with:

  • Hugging Face TEI (Text Embeddings Inference) - recommended for local inference
  • OpenAI cloud API
  • LocalAI, Ollama, vLLM, and other compatible services

Supports content-addressed caching to avoid redundant API calls.

BM25Embedder (BM25Embedder):

Pure Go lexical embeddings using BM25 (Best Matching 25) algorithm:

  • No external dependencies - works offline
  • Feature hashing to fixed dimensions
  • Stopword removal and simple stemming
  • L2 normalization for cosine similarity

Provides reasonable keyword matching but lacks semantic understanding. Use as fallback when neural services unavailable.

Storage

The package uses two NATS KV buckets:

EMBEDDING_INDEX: Primary storage for embedding records

  • Key: entity ID
  • Value: Record with vector, status, metadata
  • Statuses: pending, generated, failed

EMBEDDING_DEDUP: Content-addressed deduplication

  • Key: SHA-256 content hash
  • Value: DedupRecord with vector and entity ID list
  • Enables sharing vectors across entities with identical content

ContentStorable Support

For entities with large text content stored in ObjectStore, the worker can fetch content dynamically using StorageRef:

storage.SavePendingWithStorageRef(ctx, entityID, contentHash,
    identityText, // inline title/.signature text, embedded ahead of the body (or "")
    &embedding.StorageRef{
        StorageInstance: "main",
        Key:             "content/papers/doc123",
    },
    map[string]string{
        message.ContentRoleBody:     "full_text",
        message.ContentRoleAbstract: "abstract",
        message.ContentRoleTitle:    "title",
    },
    sourceRevision, // ENTITY_STATES revision for the readiness watermark (0 if unknown)
)

Vector Operations

The package provides common vector operations:

// Cosine similarity for semantic search
similarity := embedding.CosineSimilarity(vectorA, vectorB)
// Returns -1 to 1, where 1 = identical, 0 = orthogonal

Configuration

HTTP embedder configuration:

BaseURL:  "http://localhost:8082"  # TEI endpoint
Model:    "all-MiniLM-L6-v2"       # 384 dimensions, fast
APIKey:   ""                       # Optional for local services
Timeout:  30s                      # HTTP timeout

BM25 embedder configuration:

Dimensions: 384    # Match neural models for compatibility
K1:         1.5    # Term frequency saturation (1.2-2.0)
B:          0.75   # Length normalization (0.0-1.0)

Worker configuration:

Workers: 5         # Concurrent worker goroutines

Thread Safety

HTTPEmbedder, BM25Embedder, Storage, and Worker are safe for concurrent use. The Worker uses goroutines to process pending embeddings in parallel.

Metrics

The worker accepts a WorkerMetrics interface for observability:

  • IncDedupHits(): Embedding reused from dedup cache
  • IncFailed(): Embedding generation failed
  • SetPending(): Current pending embedding count

See Also

Related packages:

Package embedding provides embedding generation and caching for semantic search.

This package contains interfaces and implementations for generating vector embeddings from text, which are used by the indexmanager for semantic similarity search.

Index

Constants

View Source
const (
	// EmbeddingIndexBucket stores entity embeddings with metadata
	EmbeddingIndexBucket = "EMBEDDING_INDEX"

	// EmbeddingDedupBucket stores content-addressed embeddings for deduplication
	EmbeddingDedupBucket = "EMBEDDING_DEDUP"
)
View Source
const MaxSourceTextLenCeiling = 1_000_000

MaxSourceTextLenCeiling is the hard upper bound on the source-text cap, in runes. Config.Validate rejects a larger max_text_len, and fetchTextFromStorage clamps to it before deriving its byte read budget so a pathological value can never overflow utf8.UTFMax*limit+1 into a negative io.LimitReader bound (which reads an empty body that hop 2 would then treat as "no source text" and DELETE the pending embedding — #628 FIX 2). 1_000_000 characters is far past any real embedding input (neural context caps are ~8k) while keeping the worst-case offloaded read bounded at utf8.UTFMax MB.

Variables

View Source
var ErrCASExhausted = errors.New("embedding index write did not converge under revision CAS")

ErrCASExhausted reports that the revision compare-and-set loop on a save lane did not converge within maxCASRetries. It is transient (a caller may re-drive), and under the SourceRevision ordering guard it should be effectively unreachable.

View Source
var ErrRecordGone = errors.New("embedding index record no longer exists")

ErrRecordGone reports that the EMBEDDING_INDEX record a save was meant to UPDATE no longer exists, so the save was dropped without writing.

It is a normal outcome, not a fault: since gh#614 the hop-1 entity tombstone deletes an entity's index key from the watcher goroutine while a hop-2 worker may still be inside an embedder round trip for that same entity. Callers should treat it as "this entity is no longer supposed to have an embedding" and stop — in particular they must not report it as a generation failure or fire the generated callback, which would push a vector for a dead entity into caches.

View Source
var ErrSupersededRevision = errors.New("embedding index write superseded by a newer source revision")

ErrSupersededRevision reports that the save was dropped because a newer source revision's outcome already landed for this entity (the ordering guard, #614 part 2). Like ErrRecordGone it is a normal, non-failure outcome: the caller must NOT fire the generated callback, because the vector it holds is the OLDER revision's and firing would push a stale vector into any WithOnGenerated consumer's cache — the same hazard ErrRecordGone guards against, for the same reason. Callers must not report it as a failure either; the newer outcome is authoritative.

Functions

func ContentHash

func ContentHash(text string) string

ContentHash generates a SHA-256 hash of text content for use as a cache key.

This function provides consistent hashing across the codebase for content-addressed storage.

It keys on text ALONE, so it is only correct for a cache whose lifetime is scoped to a single embedder instance (the HTTPEmbedder request-local cache). It must NOT be used for the durable EMBEDDING_DEDUP bucket, which outlives any one embedder configuration — use DedupKey for that (gh#612).

func CosineSimilarity

func CosineSimilarity(a, b []float32) float64

CosineSimilarity computes the cosine similarity between two vectors.

Returns a value between -1 and 1, where:

  • 1 means vectors are identical
  • 0 means vectors are orthogonal (unrelated)
  • -1 means vectors are opposite

Formula: cos(θ) = (A · B) / (||A|| × ||B||)

func DedupKey

func DedupKey(id EmbedderIdentity, text string) string

DedupKey derives the EMBEDDING_DEDUP key for text under a given embedder identity, or "" when the identity is not yet resolved.

Every field is length-prefixed before hashing so that no two distinct field tuples can serialize to the same byte stream (without prefixes, {"ab","c"} and {"a","bc"} would collide).

An identity with a non-positive Dimensions has not learned its vector width yet (HTTPEmbedder resolves it from the first API response) and gets NO key. "" is the package's established "dedup disabled for this record" signal, which both hops already honour: it is stored as the pending record's ContentHash and getOrGenerateEmbedding generates unconditionally for it. The alternative of keying on a placeholder width is what split EMBEDDING_DEDUP into two disjoint keyspaces per embedder; omitting Dimensions from the key instead would be worse still, since keys would then collide across models of different width — exactly what folding identity into the key was meant to prevent. The cost is redundant embeds during the brief unresolved window, once per process.

func InProcessDedupKey

func InProcessDedupKey(id EmbedderIdentity, text string) string

InProcessDedupKey derives a STABLE process-local key for collapsing concurrent byte-identical embedder calls (#630), EXCLUDING the learned Dimensions.

Unlike DedupKey it ALWAYS returns a key — even before the embedder has resolved its vector width (HTTPEmbedder reports Dimensions()==0 until its first response). That is deliberate: at cold start DedupKey withholds a key, so a burst of K workers holding identical content would otherwise each issue its own paid Generate call. Keying the in-process singleflight on this width-independent value collapses them to one call, while the DURABLE dedup cache stays correctly withheld (a vector must not be stored under a wrong-dimensions key).

Excluding Dimensions is safe here precisely because this key never leaves the process: the embedder — and therefore the width it will eventually resolve — is the SAME instance across every worker goroutine, so two workers with identical content are guaranteed to be producing vectors in the same space regardless of whether the width is known yet.

Types

type BM25Config

type BM25Config struct {
	// Dimensions is the output embedding dimension (default: 384 for compatibility)
	Dimensions int

	// K1 controls term frequency saturation (default: 1.5)
	// Higher values give more weight to term frequency
	K1 float64

	// B controls length normalization (default: 0.75)
	// B=1.0 means full normalization, B=0.0 means no normalization
	B float64
}

BM25Config configures the BM25 embedder.

type BM25Embedder

type BM25Embedder struct {
	// contains filtered or unexported fields
}

BM25Embedder implements pure Go lexical embeddings using BM25 algorithm.

This embedder provides a fallback when neural embedding services are unavailable. It uses BM25 (Best Matching 25) scoring - a term-frequency based ranking function widely used in information retrieval.

The embedder generates fixed-dimension vectors by:

  1. Tokenizing text (lowercase, split on non-alphanumeric)
  2. Computing term frequencies
  3. Hashing terms to fixed dimensions (feature hashing)
  4. Applying BM25 weighting (TF with IDF and length normalization)
  5. L2 normalizing for cosine similarity compatibility

Parameters:

  • k1: Controls term frequency saturation (default 1.5)
  • b: Controls document length normalization (default 0.75)

This is a lexical approach - it won't understand semantic similarity like neural models, but provides reasonable results for exact term matches and common phrases.

func NewBM25Embedder

func NewBM25Embedder(cfg BM25Config) *BM25Embedder

NewBM25Embedder creates a new BM25-based embedder.

func (*BM25Embedder) Close

func (b *BM25Embedder) Close() error

Close releases resources (no-op for BM25).

func (*BM25Embedder) Dimensions

func (b *BM25Embedder) Dimensions() int

Dimensions returns the dimensionality of embeddings.

func (*BM25Embedder) Generate

func (b *BM25Embedder) Generate(ctx context.Context, texts []string) ([][]float32, error)

Generate creates BM25-based embeddings for the given texts.

This updates internal document statistics incrementally, so the embedder "learns" vocabulary and IDF scores from all texts it processes.

func (*BM25Embedder) GenerateQuery

func (b *BM25Embedder) GenerateQuery(ctx context.Context, texts []string) ([][]float32, error)

GenerateQuery embeds query-side text. BM25 is a symmetric bag-of-words model — there is no query/document asymmetry, so the returned vector is what Generate would produce for the same text against the same statistics (gh#438).

It is READ-ONLY over the corpus statistics (gh#619). Searching is not observing a document: folding the query's terms into docCount/termDocCount and its (typically 2-4 token) length into avgDocLength shifted the IDF weights and the length-normalization denominator for every document embedded afterwards, so a graph's rankings drifted as a function of how many times it had been searched. Generate remains the only mutator.

This does NOT make BM25 a correct lexical index — the statistics are still process-local, unpersisted, and order-dependent across restarts. That is the open decision in gh#619 (real index over an immutable snapshot vs. a stateless hashed TF vector); this only stops searches from corrupting the corpus.

func (*BM25Embedder) Model

func (b *BM25Embedder) Model() string

Model returns the model identifier.

type Cache

type Cache interface {
	// Get retrieves a cached embedding for the given content hash.
	//
	// Returns an error if the embedding is not found in the cache.
	Get(ctx context.Context, contentHash string) ([]float32, error)

	// Put stores an embedding in the cache with the given content hash.
	//
	// The cache should be content-addressed using a cryptographic hash
	// (e.g., SHA-256) of the text content.
	Put(ctx context.Context, contentHash string, embedding []float32) error
}

Cache provides content-addressed caching for embeddings.

Implementations should use a hash of the text content as the key to enable deduplication and fast lookups.

type DedupRecord

type DedupRecord struct {
	Vector         []float32 `json:"vector"`
	EntityIDs      []string  `json:"entity_ids"` // Entities sharing this content
	FirstGenerated time.Time `json:"first_generated"`
	Model          string    `json:"model,omitempty"`
	Dimensions     int       `json:"dimensions,omitempty"`
}

DedupRecord stores content-addressed embeddings for deduplication.

Model and Dimensions record WHICH vector space the stored vector belongs to. The dedup KEY already folds in embedder identity (see DedupKey, gh#612), so a mismatch here should be unreachable; carrying the fields anyway makes a stale record detectable after the fact instead of silently servable — the original defect was that a bm25 vector could be returned and re-stamped with a neural model's name with nothing in the record to contradict it.

type Embedder

type Embedder interface {
	// Generate creates embeddings for the given texts (the DOCUMENT side — what is
	// embedded at ingest).
	//
	// This is the primary method - batch operations are natural for all providers.
	// For single text, pass a slice with one element.
	// Returns a slice of float32 slices, where each inner slice is an embedding vector.
	Generate(ctx context.Context, texts []string) ([][]float32, error)

	// GenerateQuery creates embeddings for QUERY-side text (semantic search),
	// as opposed to Generate which embeds documents at ingest. Asymmetric retrieval
	// models (Snowflake arctic-embed, BGE, E5) are trained to embed a query with an
	// instruction prefix while documents are embedded raw; omitting the query prefix
	// is a measured retrieval-quality cliff, not a rounding error (gh#438). Symmetric
	// embedders (BM25) implement this identically to Generate.
	GenerateQuery(ctx context.Context, texts []string) ([][]float32, error)

	// Dimensions returns the dimensionality of embeddings produced by this embedder,
	// or 0 if it is not yet known.
	//
	// For example, all-MiniLM-L6-v2 produces 384-dimensional vectors.
	//
	// Implementations that discover the width from a remote endpoint (HTTPEmbedder)
	// report 0 until the first successful response. Implementations MUST return 0
	// rather than a plausible default: callers fold this value into durable
	// content-addressed keys (DedupKey), where a guessed width silently partitions
	// the dedup bucket into pre-guess and post-guess halves. Callers must treat 0
	// as "no stable vector space yet" and skip keyed dedup entirely.
	//
	// This value must be safe to read concurrently with Generate — the graph-embedding
	// component reads it from the hop-1 watcher goroutine and from every hop-2 worker.
	Dimensions() int

	// Model returns the model identifier used by this embedder.
	//
	// This is useful for debugging and logging which model is being used.
	Model() string

	// Close releases any resources held by the embedder.
	//
	// Must be called when the embedder is no longer needed. For HTTP providers
	// this is typically a no-op, but for local ONNX models this releases GPU/CPU resources.
	Close() error
}

Embedder generates vector embeddings for text.

Implementations can use different providers (HTTP APIs, BM25, etc.) while maintaining a consistent interface. All providers support batch operations natively, following OpenAI API patterns.

type EmbedderIdentity

type EmbedderIdentity struct {
	// Type is the configured embedder kind ("bm25", "http").
	Type string
	// Model is the embedder's model identifier.
	Model string
	// Dimensions is the vector width the embedder produces.
	Dimensions int
	// MaxTextLen is the source-text truncation cap applied before generation.
	// It belongs in the key because the cap is derived from the embedder type
	// (4000 for bm25, 8000 otherwise): flipping type changes the TEXT that gets
	// embedded, not only the model that embeds it.
	MaxTextLen int
}

EmbedderIdentity captures everything a stored vector depends on BESIDES the input text. Two embedders that disagree on any of these fields produce vectors in different, incomparable vector spaces.

It exists because EMBEDDING_DEDUP is durable, untimed, and never cleared: an operator who switches embedder_type bm25 -> http against the same NATS state would otherwise get every already-embedded entity's OLD vector back, stamped with the NEW model's name. When both spaces happen to share a dimension count (384 is the default for both BM25 here and TEI's all-MiniLM-L6-v2), cosine similarity returns a plausible-looking score across unrelated spaces and no health signal fires. Folding identity into the key makes that impossible: stale keys simply never match again and age out.

type FailedEntry

type FailedEntry struct {
	EntityID       string
	Reason         string
	SourceRevision uint64
}

FailedEntry is one entity currently in a failed embedding terminal state, as read by the current-failed bootstrap scan (#613): the entity ID, its bounded reason, and the source revision the failure was recorded at.

Reason is ALWAYS a member of the bounded failure enum (or reasonUnknown) — ScanFailed normalizes it, so a record written by a future or rolled-back worker can never inject an arbitrary string into the readiness envelope's reason histogram (#613 F5).

SourceRevision is the durable record's revision, seeded into the in-memory failed map so the component's revision-CAS (a superseded older completion must NOT clear a newer failure) starts from the correct baseline after a restart (#613 F1), rather than from an unknown-revision floor a stale older completion could then clear.

type GeneratedCallback

type GeneratedCallback func(entityID string, embedding []float32)

GeneratedCallback is called when an embedding is successfully generated. The callback receives the entity ID and the generated embedding vector.

type HTTPConfig

type HTTPConfig struct {
	// BaseURL is the base URL of the embedding service.
	// Examples:
	//   - "http://localhost:8082" (TEI - Hugging Face Text Embeddings Inference)
	//   - "http://tei:8082" (TEI container by name)
	//   - "http://localhost:8080" (LocalAI)
	//   - "https://api.openai.com/v1" (OpenAI cloud)
	BaseURL string

	// Model is the embedding model to use.
	// Examples:
	//   - "all-MiniLM-L6-v2" (TEI default - 384 dims, fast)
	//   - "all-mpnet-base-v2" (TEI - 768 dims, higher quality)
	//   - "text-embedding-ada-002" (OpenAI)
	Model string

	// APIKey for authentication (optional for local services).
	// Required for OpenAI, optional for TEI/LocalAI.
	APIKey string

	// QueryPrefix is prepended to query-side text only (GenerateQuery), never to
	// documents. Set it to the model's query instruction for asymmetric retrieval
	// models — e.g. "Represent this sentence for searching relevant passages: " for
	// Snowflake arctic-embed / E5 (gh#438). Empty disables the prefix (symmetric use).
	QueryPrefix string

	// Timeout for HTTP requests (default: 30s).
	Timeout time.Duration

	// Cache for embedding results (optional but recommended).
	Cache Cache

	// Logger for error logging (optional, defaults to slog.Default()).
	Logger *slog.Logger

	// IdleConnTimeout, ResponseHeaderTimeout, and DisableKeepAlives
	// mirror the same fields on model.EndpointConfig and graph/llm's
	// OpenAIConfig — operator overrides for connection-hygiene
	// behaviour. Empty/false selects the framework default (see
	// model.NewHTTPClient).
	IdleConnTimeout       string
	ResponseHeaderTimeout string
	DisableKeepAlives     bool
}

HTTPConfig configures the HTTP embedder.

type HTTPEmbedder

type HTTPEmbedder struct {
	// contains filtered or unexported fields
}

HTTPEmbedder calls an external OpenAI-compatible embedding service via HTTP.

This implementation works with:

  • Hugging Face TEI (Text Embeddings Inference) - recommended, containerized
  • LocalAI (self-hosted)
  • OpenAI (cloud)
  • Any OpenAI-compatible embedding API

Uses the standard OpenAI SDK for consistency and compatibility. See Dockerfile.tei and docker-compose.services.yml for ready-to-use TEI setup.

func NewHTTPEmbedder

func NewHTTPEmbedder(cfg HTTPConfig) (*HTTPEmbedder, error)

NewHTTPEmbedder creates a new HTTP-based embedder.

func (*HTTPEmbedder) Close

func (h *HTTPEmbedder) Close() error

Close releases resources (no-op for HTTP client).

func (*HTTPEmbedder) Dimensions

func (h *HTTPEmbedder) Dimensions() int

Dimensions returns the dimensionality of embeddings produced, or 0 if the endpoint has not answered yet.

0 is reported honestly rather than guessed. Callers that key durable state on the vector space (DedupKey) must refuse to build a key from an unresolved identity; inventing a width there is what partitioned EMBEDDING_DEDUP.

func (*HTTPEmbedder) Generate

func (h *HTTPEmbedder) Generate(ctx context.Context, texts []string) ([][]float32, error)

Generate creates DOCUMENT-side embeddings by calling the external HTTP service.

This method checks the cache first (if configured), then calls the embedding API for any cache misses.

func (*HTTPEmbedder) GenerateQuery

func (h *HTTPEmbedder) GenerateQuery(ctx context.Context, texts []string) ([][]float32, error)

GenerateQuery creates QUERY-side embeddings, prepending the configured query instruction prefix to each text (asymmetric retrieval models, gh#438). With no prefix configured it is identical to Generate. The cache keys on the prefixed text, so query and document embeddings of the same string never collide.

func (*HTTPEmbedder) Model

func (h *HTTPEmbedder) Model() string

Model returns the model identifier.

type NATSCache

type NATSCache struct {
	// contains filtered or unexported fields
}

NATSCache implements Cache using NATS KV for storage.

Embeddings are stored with content-addressed keys (SHA-256 hash of text) to enable deduplication and fast lookups.

func NewNATSCache

func NewNATSCache(bucket jetstream.KeyValue) *NATSCache

NewNATSCache creates a new NATS KV-backed embedding cache.

func (*NATSCache) Get

func (c *NATSCache) Get(ctx context.Context, contentHash string) ([]float32, error)

Get retrieves a cached embedding by content hash.

func (*NATSCache) Put

func (c *NATSCache) Put(ctx context.Context, contentHash string, embedding []float32) error

Put stores an embedding in the cache with the given content hash.

type Record

type Record struct {
	EntityID    string    `json:"entity_id"`
	Vector      []float32 `json:"vector,omitempty"`
	ContentHash string    `json:"content_hash"`
	// SourceText is the INLINE lane's WHOLE embedding text (the identity triples
	// extracted at hop 1). It is meaningful ONLY on the inline lane (StorageRef == nil);
	// on the offloaded lane it is left EMPTY and the identity prefix travels in
	// IdentityText instead. Keeping the offloaded identity OUT of SourceText is a
	// deliberate cross-version contract: a pre-#635 worker's getSourceText is
	// SourceText-primary, so an offloaded identity stored here would make such a worker
	// embed identity-only and silently drop the body (see IdentityText, #635 retro F1).
	SourceText string `json:"source_text,omitempty"`
	// IdentityText is the inline identity PREFIX (title/.signature/.comment, per the
	// configured text suffixes) for the OFFLOADED (StorageRef) lane ONLY: hop 2 embeds it
	// AHEAD of the fetched body, identity-first, in one vector so text_suffixes takes
	// effect on offloaded entities too (D1/D2, #601). Empty on the inline lane, and empty
	// on an offloaded record with no inline identity text (body-only).
	//
	// It is a field DISTINCT from SourceText on purpose (#635 retro F1). Pending records
	// are durable and re-delivered via WatchAll, so a PRE-#635 worker can consume a record
	// this (post-#635) writer produced during a rolling upgrade or after a rollback. That
	// old worker does not know this field, ignores it, sees SourceText == "" with
	// StorageRef set, and falls back to fetching the body — the safe pre-#635 behavior. Had
	// the identity been overloaded onto SourceText (as #635 originally did), the old worker
	// would have taken its SourceText branch and embedded identity-only, silently losing the
	// body.
	IdentityText string    `json:"identity_text,omitempty"`
	Model        string    `json:"model,omitempty"`
	Dimensions   int       `json:"dimensions,omitempty"`
	GeneratedAt  time.Time `json:"generated_at,omitempty"`
	Status       Status    `json:"status"`
	ErrorMsg     string    `json:"error_msg,omitempty"` // If status=failed
	// Reason is a BOUNDED classification of a failure (status=failed), stored next to
	// the raw ErrorMsg (#613). It is the value the failures metric is labelled by — the
	// raw ErrorMsg is unbounded and must NEVER be a metric label (cardinality blowup).
	// Additive/omitempty: a record written by a pre-#613 worker carries no reason, and a
	// rolled-back worker ignores the field, so it is wire-compatible in both directions.
	// It is also what the current-failed bootstrap scan reads to seed the reason
	// breakdown after a restart.
	Reason string `json:"reason,omitempty"` // Bounded reason enum when status=failed

	// SourceRevision is the ENTITY_STATES stream revision that produced this record.
	// It is threaded from the hop-1 watcher so hop-2 can complete the embedding
	// readiness watermark at the terminal transition (ADR-066 §3), and it is now
	// PERSISTED onto generated/failed records so SaveGenerated/SaveFailed can order
	// concurrent writes by it: a record already carrying a higher SourceRevision has
	// a newer vector, so a late older-revision write is dropped (#614 part 2). 0
	// means "unknown" (a legacy record written before this field existed) — treated
	// as oldest, so any real revision wins the ordering guard, and the watermark
	// completion treats 0 as a no-op.
	SourceRevision uint64 `json:"source_revision,omitempty"`

	// ContentStorable support (Feature 008)
	// When StorageRef is set, Worker fetches content from ObjectStore
	// and uses ContentFields to extract text for embedding.
	StorageRef    *StorageRef       `json:"storage_ref,omitempty"`
	ContentFields map[string]string `json:"content_fields,omitempty"` // Role → field name
}

Record represents a stored embedding with metadata

type ScoredEntity

type ScoredEntity struct {
	EntityID   string
	Similarity float64
}

ScoredEntity pairs an entity ID with its cosine similarity score. Returned by FindSimilarFromCache for zero-KV similarity queries.

type Status

type Status string

Status represents the processing status of an embedding

const (
	// StatusPending awaits generation
	StatusPending Status = "pending"
	// StatusGenerated is successfully generated
	StatusGenerated Status = "generated"
	// StatusFailed indicates generation failed
	StatusFailed Status = "failed"
)

type Storage

type Storage struct {
	// contains filtered or unexported fields
}

Storage handles persistence of embeddings to NATS KV buckets. It also maintains an in-memory vector cache, kept current via a KV watcher on the index bucket, to serve similarity queries without any network round-trips.

func NewStorage

func NewStorage(indexBucket, dedupBucket jetstream.KeyValue) *Storage

NewStorage creates a new embedding storage instance

func (*Storage) DeleteEmbedding

func (s *Storage) DeleteEmbedding(ctx context.Context, entityID string) error

DeleteEmbedding removes an embedding record

func (*Storage) FindSimilarFromCache

func (s *Storage) FindSimilarFromCache(excludeID string, queryVector []float32, keep func(string) bool, limit int) ([]ScoredEntity, bool)

FindSimilarFromCache scans the in-memory vector cache for entities whose cosine similarity to queryVector is highest, excluding the entity identified by excludeID (pass "" to skip exclusion).

keep, when non-nil, is a candidate predicate applied BEFORE cosine similarity: only entity IDs for which keep returns true are scored. Pass nil to score every cached entity (no filter). This is how a scoped semantic search (ADR-071) constrains candidates at the source on the warm path — the caller builds keep from the requested ID prefixes so filtering happens before the expensive cosine, and identically to the cold KV-scan fallback.

The second return value reports whether the cache was ready (warm) and its maintaining watcher was still healthy at the time of the call. Callers must fall back to authoritative KV when it is false.

func (*Storage) GetByContentHash

func (s *Storage) GetByContentHash(ctx context.Context, contentHash string) (*DedupRecord, error)

GetByContentHash retrieves an embedding by content hash (for deduplication)

func (*Storage) GetEmbedding

func (s *Storage) GetEmbedding(ctx context.Context, entityID string) (*Record, error)

GetEmbedding retrieves an embedding by entity ID

func (*Storage) ListGeneratedEntityIDs

func (s *Storage) ListGeneratedEntityIDs(ctx context.Context) ([]string, error)

ListGeneratedEntityIDs returns all entity IDs that have embeddings in storage. This is used for pre-warming the vector cache on startup.

func (*Storage) SaveDedup

func (s *Storage) SaveDedup(
	ctx context.Context,
	contentHash string,
	vector []float32,
	entityID, model string,
	dimensions int,
) error

SaveDedup saves a content-addressed embedding for deduplication.

model and dimensions identify the vector space the vector belongs to; callers pass the generating embedder's own values so a stale record is auditable (gh#612). contentHash MUST come from DedupKey, not ContentHash — the durable dedup bucket outlives any one embedder configuration.

func (*Storage) SaveFailed

func (s *Storage) SaveFailed(ctx context.Context, entityID, errorMsg, reason string, sourceRevision uint64) error

SaveFailed marks an embedding as failed under the same revision CAS and ordering guard as SaveGenerated, so a stale failure cannot clobber a newer success and the failed record persists its own source revision for later ordering.

reason is the BOUNDED classification stored alongside the raw errorMsg (#613); it is what the failures metric is labelled by and what the current-failed bootstrap scan reads. Pass "" only for a failure that has no classified reason.

func (*Storage) SaveGenerated

func (s *Storage) SaveGenerated(
	ctx context.Context,
	entityID string,
	vector []float32,
	model string,
	dimensions int,
	contentHash string,
	sourceRevision uint64,
) error

SaveGenerated persists a generated embedding under revision compare-and-set, ordered by source revision.

contentHash is the hop-2 dedup key of the exact bytes embedded (#623); it is STORED as the record's content hash rather than copied from the pending record, which since the hop-2 key move carries an empty hash. sourceRevision is the ENTITY_STATES revision this generation is completing. ContentHash and Vector are both taken from the passed arguments — never from `existing` — so they can never desync across revisions (#614 part 2).

The read of `existing` no longer exists to copy a field forward; it exists ONLY for the CAS revision and the ordering guard.

func (*Storage) SavePending

func (s *Storage) SavePending(ctx context.Context, entityID, contentHash, sourceText string, sourceRevision uint64) error

SavePending saves a pending embedding request with source text (legacy mode). sourceRevision is the ENTITY_STATES revision that produced this record (ADR-066 §3 readiness watermark); pass 0 when unknown.

func (*Storage) SavePendingWithStorageRef

func (s *Storage) SavePendingWithStorageRef(
	ctx context.Context,
	entityID, contentHash, identityText string,
	storageRef *StorageRef,
	contentFields map[string]string,
	sourceRevision uint64,
) error

SavePendingWithStorageRef saves a pending embedding request with storage reference. This enables the ContentStorable pattern where text is fetched from ObjectStore. The contentHash is still used for deduplication if provided.

identityText is the entity's INLINE identity text (title/.signature/.comment, selected by the configured text suffixes at hop 1). It is stored in Record.IdentityText — a field DISTINCT from SourceText — and hop 2 embeds it AHEAD of the fetched body, identity-first, in one vector so the text-suffix config takes effect on offloaded entities too (D1/D2). Pass "" for an offloaded entity with no inline text, and hop 2 embeds the body alone. SourceText is left EMPTY on this offloaded record so a pre-#635 worker consuming it (rolling upgrade / rollback) falls back to fetching the body rather than embedding identity-only and dropping the body (see Record.IdentityText, #635 retro F1).

func (*Storage) ScanFailed

func (s *Storage) ScanFailed(ctx context.Context) ([]FailedEntry, error)

ScanFailed enumerates EMBEDDING_INDEX via its WatchAll initial snapshot and returns every record currently in the StatusFailed terminal state, with its bounded reason. It seeds the component's in-memory current-failed map at Start so FailedCount (and therefore the degraded verdict) is accurate immediately after bootstrap, independent of re-delivery timing (#613). It uses the same last-per-subject snapshot pass as StartVectorCache (precedent storage.go:665) — one streamed read of the current values, not a Get per key — and returns at the nil initial-sync sentinel. A record that will not decode is skipped (best-effort seed); aborting would leave FailedCount at 0 (false-not-degraded), which is worse than a partial seed corrected by re-delivery.

func (*Storage) StartVectorCache

func (s *Storage) StartVectorCache(ctx context.Context) error

StartVectorCache launches a goroutine that keeps the in-memory vector cache synchronised with the EMBEDDING_INDEX KV bucket via WatchAll.

The goroutine runs until ctx is cancelled. It is safe to call only once; a second call is a no-op. cacheReady is closed after the initial snapshot has been applied (nil delimiter received), so FindSimilarFromCache will not return results until the cache is warm.

type StorageRef

type StorageRef struct {
	StorageInstance string `json:"storage_instance"`
	Key             string `json:"key"`
}

StorageRef is a simplified reference for embedding storage. Mirrors message.StorageReference structure.

type StoreResolver

type StoreResolver interface {
	Streamable(instance string) (storage.StreamableStore, bool)
}

StoreResolver resolves a StorageReference.StorageInstance to its live streaming store (ADR-063). *storeregistry.Registry satisfies it. The worker resolves per-fetch and never caches the returned handle — it is owned by the storage component, not the worker.

type TerminalCallback

type TerminalCallback func(entityID string, sourceRevision uint64, outcome TerminalOutcome, reason string)

TerminalCallback is called when a pending embedding reaches ANY terminal outcome — generated, failed, or deliberately skipped (no text) — carrying the entity ID, the ENTITY_STATES SourceRevision that produced the record, the TerminalOutcome, and (for OutcomeFailed) the bounded failure reason. It exists so the hop-1 readiness watermark can be completed at the true end of the two-hop pipeline (ADR-066 §3) and the current-failed map routed by outcome (#613). sourceRevision==0 means "unknown" (a legacy record) and the completion is a no-op; ^uint64(0) is the max-rev drain used for an unreadable (corrupt) record whose revision cannot be recovered. reason is non-empty only for OutcomeFailed.

type TerminalOutcome

type TerminalOutcome int

TerminalOutcome classifies HOW a pending embedding reached its terminal state, so the readiness component can route its current-failed map (#613): OutcomeFailed adds the entity (with its bounded reason), every other outcome removes it. It does NOT change the watermark, which advances on ALL terminal outcomes regardless — that deadlock-avoidance property (a permanently-failing or no-text entity never pins the watermark) is deliberately untouched.

const (
	// OutcomeGenerated means a usable vector was stored.
	OutcomeGenerated TerminalOutcome = iota
	// OutcomeFailed means a durable StatusFailed record was written for this entity — the
	// only outcome that ADDS to the current-failed map.
	OutcomeFailed
	// OutcomeSkipped means nothing to embed (no text), a corrupt-record drain, or a
	// failure attempt that did NOT persist a durable failed record (superseded /
	// record-gone / context-cancelled). None is a current failure, so all REMOVE the
	// entity from the current-failed map.
	OutcomeSkipped
	// OutcomeDeleted means the record was tombstoned or superseded by a newer revision
	// mid-generation; like Skipped it is not a current failure and removes the entity
	// from the current-failed map.
	OutcomeDeleted
)

type Worker

type Worker struct {
	// contains filtered or unexported fields
}

Worker processes pending embedding requests asynchronously

func NewWorker

func NewWorker(
	storage *Storage,
	embedder Embedder,
	indexBucket jetstream.KeyValue,
	logger *slog.Logger,
) *Worker

NewWorker creates a new async embedding worker

func (*Worker) Start

func (w *Worker) Start(ctx context.Context) error

Start begins watching for pending embeddings and processing them

func (*Worker) Stop

func (w *Worker) Stop() error

Stop stops the embedding worker gracefully

func (*Worker) WithContentStore

func (w *Worker) WithContentStore(store storage.StreamableStore) *Worker

WithContentStore sets the OWNED fallback content store for streaming body text retrieval. Used only when the shared resolver cannot resolve a ref's StorageInstance (single-bucket / legacy store-read deploys). The component owns and closes this store.

func (*Worker) WithEmbedderType

func (w *Worker) WithEmbedderType(t string) *Worker

WithEmbedderType sets the Type axis of the dedup-key identity ("bm25" / "http"). Hop 2 folds it (with the embedder's live Model/Dimensions and the text cap) into the dedup key so a config that flips embedder type cannot serve a vector from the prior vector space (gh#612). The worker derives the key itself now, so this is the one identity field it cannot read from the Embedder interface.

func (*Worker) WithMaxSourceTextLen

func (w *Worker) WithMaxSourceTextLen(n int) *Worker

WithMaxSourceTextLen sets the maximum characters for source text used in embedding generation. Text beyond this limit is truncated at a word boundary. Default: 0 (unlimited). Recommended: 4000 for BM25, 8000 for neural.

func (*Worker) WithMetrics

func (w *Worker) WithMetrics(m WorkerMetrics) *Worker

WithMetrics sets the metrics reporter for observability.

func (*Worker) WithOnGenerated

func (w *Worker) WithOnGenerated(cb GeneratedCallback) *Worker

WithOnGenerated sets a callback that is invoked when an embedding is generated. Use this to populate caches or trigger downstream processing.

func (*Worker) WithOnTerminal

func (w *Worker) WithOnTerminal(cb TerminalCallback) *Worker

WithOnTerminal sets a callback invoked when a pending embedding reaches any terminal outcome (generated, failed, or no-text skip). Used to complete the hop-1 readiness watermark (ADR-066 §3).

func (*Worker) WithStoreResolver

func (w *Worker) WithStoreResolver(r StoreResolver) *Worker

WithStoreResolver sets the shared store resolver (ADR-063). This is the primary content-fetch path: a StorageRef's StorageInstance is resolved to the live store that owns it, so the worker fetches offloaded bodies from ANY registered storage instance, not just one wired bucket. Resolved per-fetch; never cached.

func (*Worker) WithWorkers

func (w *Worker) WithWorkers(n int) *Worker

WithWorkers sets the number of concurrent workers.

n is floored at 1: Start spawns exactly n goroutines to drain the KV watcher, so a zero or negative count is not "fewer workers", it is a component that silently consumes nothing while every health signal stays green. No caller ever wants that, so it is corrected here rather than at each call site (gh#620).

type WorkerMetrics

type WorkerMetrics interface {
	// IncDedupHits increments the deduplication hits counter
	IncDedupHits()
	// IncDedupSkipped counts an embedding generated on a condition where the durable
	// dedup bucket was NOT consulted (currently: an embedder whose vector width is
	// unresolved, so no content-addressed key can be derived). It makes the
	// avoided-reuse cost visible rather than inferred (#623): the offloaded-lane
	// re-embed cost Track 0 measured, and, post-fix, its recovery.
	IncDedupSkipped(reason string)
	// IncTruncated counts one source-text truncation at the configured cap, so the
	// bytes actually embedded are discoverable rather than silently dropped (#602).
	IncTruncated()
	// IncOffloadedIdentityIncluded counts one offloaded (StorageRef) entity for which a
	// vector was STORED that included its inline identity text (title/.signature/.comment,
	// per text_suffixes) AHEAD of the body. It fires on the successful-persistence path
	// (with IncDedupHits), not at text production — a dropped save does not count (#635
	// retro F3). Paired with IncOffloadedIdentityAbsent, it makes the text-suffix effect on
	// the offloaded lane observable: a producer tuning text_suffixes confirms it took effect
	// from /metrics rather than inferring it from silence (D5/#601).
	IncOffloadedIdentityIncluded()
	// IncOffloadedIdentityAbsent counts one offloaded (StorageRef) entity for which a
	// vector was STORED from its body ALONE — no inline identity text was present. Like its
	// symmetric half it fires only on the successful-persistence path (#635 retro F3): an
	// offloaded entity whose embed failed, or one with neither identity nor body (deleted
	// before generation), counts neither. Lets a producer tell a config-effect from silence
	// (D5/#601).
	IncOffloadedIdentityAbsent()
	// IncFailed increments the failed embeddings counter
	IncFailed()
	// IncFailedReason increments the reason-labelled failures counter (#613). reason is
	// a value from the BOUNDED failure enum (see the failReason* constants) — never the
	// raw error message, which is unbounded and would blow up label cardinality. It
	// fires on the SAME terminal path as IncFailed so it stays a strict partition of the
	// total (mirrors fusion body_hydration_failures_total{reason}, inc 2).
	IncFailedReason(reason string)
	// SetPending sets the current pending embeddings gauge
	SetPending(count float64)
	// IncContentResolveError counts a body fetch that FAILED after a store was
	// resolved (an infra fault: read error, deleted bucket) — distinct from the
	// component-side content_unresolved (no store wired at all). Preserves the
	// gh#414 diagnosability the ADR-063 resolver would otherwise blur (M1).
	IncContentResolveError()
	// IncContentResolved counts a body successfully fetched from a resolved store.
	// This is the POSITIVE observable for the ADR-063 H2 behavior change: offloaded
	// bodies that configs without a store-read port previously excluded now embed —
	// a rising value is that inclusion happening, not merely content_unresolved
	// falling (the cost-ledger "make the delta observable" discipline).
	IncContentResolved()
}

WorkerMetrics provides metrics callbacks for embedding worker operations. This allows the worker to report metrics without direct dependency on prometheus.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL