spanembed

package
v0.24.0 Latest Latest
Warning

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

Go to latest
Published: Jul 14, 2026 License: AGPL-3.0 Imports: 18 Imported by: 0

Documentation

Overview

Package spanembed embeds the span projection for semantic search.

Embeddings are a derived-side concern: the derive/worker family is the single writer (the ingest hot path never embeds), and every embedding is keyed by deterministic span identity (org_id, trace_id, span_id) so the pass is idempotent — re-deriving or re-running embeds each span exactly once, skipping identities whose content, model, and dimensions are already current.

Only call_kind="main" llm spans embed. Shadow calls (permission checks, title generation) are the plurality of llm calls in a harness session and poison search relevance; tool and event spans carry payloads better served by structured queries. The embedded text is the span's delta-only content — the fresh input blocks plus the response blocks rendered to text — never the re-sent conversation history, which is what keeps selective embedding cheap.

Index

Constants

View Source
const DefaultBatchSize = 100

DefaultBatchSize bounds one candidate page.

View Source
const DefaultMaxTextBytes = 1 << 20

DefaultMaxTextBytes caps a single span's rendered text. Chunking splits an oversized span into ceil(tokens/budget) pieces, so an unbounded span would fan out into hundreds of embed calls and rows and hold several copies of its text in memory at once. Past this ceiling the span is recorded as a deterministic "too_large" failure instead — bounding per-span memory, embed cost, and chunk count regardless of batch size or pod limits. ~1 MiB is ~33 chunks at the model's per-chunk budget. 0 disables the guard.

View Source
const (
	// DefaultTableName is the span-embedding table. It lives in the
	// same database as the versioned spans projection — candidate selection
	// and search both join against the current physical table family.
	DefaultTableName = "span_embeddings"
)

Variables

View Source
var ErrNotInitialized = errors.New("span embeddings not initialized: run the embed pass (tapes serve embed-worker or tapes dev embed-spans)")

ErrNotInitialized is returned by reads when the embedding table does not exist yet — i.e. no embed pass has ever run against this store.

Functions

func ContentHash

func ContentHash(text string) string

ContentHash returns the hex sha256 of the rendered span text — the change detector that makes re-derives cheap: unchanged content under an unchanged model never re-embeds.

func RenderSpanText

func RenderSpanText(input, output json.RawMessage) string

RenderSpanText renders a span's stored delta-only content to the text that gets embedded: the text blocks of the fresh input followed by the text blocks of the output. Tool payloads, thinking, and images are deliberately excluded — they are structured data, not prose, and they drown the signal the search exists for.

Types

type Candidate

type Candidate struct {
	OrgID     string
	TraceID   string
	SpanID    string
	SessionID string // "" when the span derived without attribution

	// Input and Output are the span's stored delta-only content-block
	// arrays (JSONB), nil when empty.
	Input  json.RawMessage
	Output json.RawMessage

	// ExistingHash and ExistingModel describe the current embedding
	// row; both empty when the span has never been embedded.
	ExistingHash  string
	ExistingModel string

	// ExistingFailHash and ExistingFailModel describe a recorded
	// deterministic embed failure; both empty when the span has no
	// failure marker. A span whose current content and model match a
	// recorded failure is skipped instead of re-attempted.
	ExistingFailHash  string
	ExistingFailModel string
}

Candidate is one main llm span considered for embedding, joined with its existing embedding row (zero values when not yet embedded).

func (*Candidate) Key

func (c *Candidate) Key() Key

Key returns the candidate's pagination key.

type ChunkRecord added in v0.19.0

type ChunkRecord struct {
	OrgID       string
	TraceID     string
	SpanID      string
	SessionID   string
	Model       string
	ContentHash string
	Embeddings  [][]float32
}

ChunkRecord is one span's embedding write. A span that fit the model's context window has a single embedding; an oversized span was split into pieces and carries one embedding per piece, stored under chunk_idx 0..N-1 in slice order.

type FailureRecord added in v0.19.0

type FailureRecord struct {
	OrgID       string
	TraceID     string
	SpanID      string
	SessionID   string
	Model       string
	ContentHash string
	Reason      string
	ErrorDetail string
	TokenCount  int
}

FailureRecord captures a deterministic embed failure so the pass can stop retrying the span while keeping the loss observable: why it failed, how big the input was, and (via the store) how many times it has failed.

type Hit

type Hit struct {
	TraceID    string
	SpanID     string
	SessionID  string
	Score      float32
	UserPrompt string
	Snippet    string
	Model      string
	StartedAt  time.Time
}

Hit is one similarity-search result with its trace/turn context.

type Key

type Key struct {
	OrgID   string
	TraceID string
	SpanID  string
}

Key orders candidates for keyset pagination.

type Pass

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

Pass walks every eligible span and embeds the ones whose embedding is missing or stale. Idempotent by construction: span identity keys the writes and a content hash gates them, so running the pass twice (or concurrently with a re-derive) embeds each span at most once per content+model.

func NewPass

func NewPass(src Source, sink Sink, embedder embeddings.Embedder, cfg PassConfig, log *slog.Logger) (*Pass, error)

NewPass creates an embed pass.

func (*Pass) Run

func (p *Pass) Run(ctx context.Context) (*Report, error)

Run executes one full pass: prune orphaned embeddings, then page through every main llm span and embed the missing/stale ones.

Error discipline: a per-span embed or write failure is counted and logged but never aborts the pass — the span stays un-embedded and the next run retries it. Only infrastructure failures (candidate listing) abort, since they would starve every remaining page.

type PassConfig

type PassConfig struct {
	// Model names the embedding model; stored per row so a model
	// switch re-embeds existing spans.
	Model string

	// Dimensions is the expected embedding dimensionality. The first
	// vector the model returns is checked against it, so a
	// model/dims misconfiguration aborts the pass with one clear
	// error instead of failing every row's insert against the sized
	// vector column.
	Dimensions uint

	// BatchSize bounds one candidate page (default DefaultBatchSize).
	BatchSize int

	// MaxTextBytes caps a span's rendered text; a larger span is recorded
	// as a "too_large" failure rather than embedded, bounding per-span
	// memory, chunk count, and embed cost. Zero takes DefaultMaxTextBytes;
	// negative disables the guard.
	MaxTextBytes int
}

PassConfig configures one embed pass.

type Report

type Report struct {
	// Scanned counts every candidate span considered.
	Scanned int `json:"scanned"`
	// Embedded counts spans embedded this pass (new or re-embedded
	// after a content/model change).
	Embedded int `json:"embedded"`
	// UpToDate counts spans skipped because their embedding already
	// matches the current content and model.
	UpToDate int `json:"up_to_date"`
	// Empty counts spans skipped because their delta content renders
	// to no text at all (e.g. a pure tool-call response).
	Empty int `json:"empty"`
	// Poisoned counts spans skipped because they already failed
	// deterministically under this content and model; they are not
	// re-attempted until their content or model changes.
	Poisoned int `json:"poisoned"`
	// Chunked counts spans whose text exceeded the model's context
	// window and was split into multiple embedded pieces.
	Chunked int `json:"chunked"`
	// ChunkRows counts total embedding rows written this pass (one per
	// piece), so chunked spans contribute more than one.
	ChunkRows int `json:"chunk_rows"`
	// Oversized counts spans the model rejected as too large (whether the
	// split then succeeded or exhausted the depth cap).
	Oversized int `json:"oversized"`
	// Failed counts spans whose embed or write failed this pass — both
	// transient faults (retried next pass) and deterministic ones (also
	// recorded and skipped thereafter).
	Failed int `json:"failed"`
	// Pruned counts orphaned embedding rows removed (their span was
	// pruned or reclassified by a re-derive).
	Pruned int64 `json:"pruned"`

	// OversizeTokens holds the reported/estimated token count of each
	// oversized span this pass — the per-observation source for the embed
	// worker's oversize-tokens histogram. Excluded from JSON to keep the
	// logged summary scalar.
	OversizeTokens []int `json:"-"`
	// FailuresByReason counts deterministic failures recorded this pass,
	// keyed by reason (e.g. "oversize", "api_400"). Excluded from JSON.
	FailuresByReason map[string]int `json:"-"`
}

Report summarizes one embed pass.

type Sink

type Sink interface {
	UpsertSpanChunks(ctx context.Context, rec ChunkRecord) error
	RecordFailure(ctx context.Context, rec FailureRecord) error
	PruneOrphans(ctx context.Context) (int64, error)
}

Sink persists embeddings. *Store implements it; tests substitute a fake.

type Source

type Source interface {
	ListCandidates(ctx context.Context, after Key, limit int) ([]Candidate, error)
}

Source lists embed candidates. *Store implements it; tests substitute a fake.

type Store

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

Store reads and writes span embeddings in the tapes Postgres database. The write path (EnsureSchema, Upsert, PruneOrphans) belongs to the derive-side embed pass; the read path (Search) backs the API's span search.

func NewStore

func NewStore(pool *pgxpool.Pool, cfg StoreConfig, log *slog.Logger) (*Store, error)

NewStore wraps an existing connection pool. It performs no IO: writers must call EnsureSchema before upserting, readers may query immediately and receive ErrNotInitialized until a writer has run.

func (*Store) EnsureSchema

func (s *Store) EnsureSchema(ctx context.Context) error

EnsureSchema creates the embedding table and its HNSW index sized to the configured dimensions, or fail-fasts when an existing table was created with different dimensions. pgvector cannot resize a column in place, so a dims change requires re-embedding into a new (or dropped) table — the error says so instead of letting every subsequent insert fail.

func (*Store) ListCandidates

func (s *Store) ListCandidates(ctx context.Context, after Key, limit int) ([]Candidate, error)

ListCandidates pages every main llm span (keyset-ordered) joined with its current embedding state and any recorded failure. All of a span's chunk rows share one content hash and model, so the embedding join collapses them to a single row; the failure table is already one row per span. The caller decides per candidate whether an embed is due — content hashing happens in Go, so the query stays a cheap indexable join.

func (*Store) PruneOrphans

func (s *Store) PruneOrphans(ctx context.Context) (int64, error)

PruneOrphans removes embedding and failure rows whose span no longer exists as a main llm span — pruned by a re-derive, or reclassified out of the embeddable set. Returns the total number of rows removed across both tables.

func (*Store) RecordFailure added in v0.19.0

func (s *Store) RecordFailure(ctx context.Context, rec FailureRecord) error

RecordFailure marks a span as deterministically un-embeddable under its current content and model. The candidate gate skips such spans until their content (hash) or model changes; attempts accrue across passes so the table shows how persistent the failure is.

func (*Store) Search

func (s *Store) Search(ctx context.Context, orgID string, embedding []float32, topK int) ([]Hit, error)

Search returns the topK spans most similar to the query embedding, joined with their trace context (turn prompt and span payloads for the snippet). A span may have several chunk embeddings; results collapse to one hit per span, scored by the span's best-matching chunk. Scoped to one org — search is a tenant-facing read.

func (*Store) UpsertSpanChunks added in v0.19.0

func (s *Store) UpsertSpanChunks(ctx context.Context, rec ChunkRecord) error

UpsertSpanChunks writes a span's embeddings as chunk rows 0..N-1 and removes any higher-indexed rows left over from a previous, longer chunking of the same span — all in one transaction so search never sees a partially-rewritten span. A successful write also clears any failure marker for the span: the content that once failed now embeds. Re-derives keep span identity stable, so re-embedding overwrites in place.

type StoreConfig

type StoreConfig struct {
	// TableName defaults to DefaultTableName.
	TableName string

	// Dimensions is the embedding dimensionality. The table's vector
	// column is created with exactly this size and EnsureSchema
	// fail-fasts when an existing table disagrees — model and dims are
	// a deliberate, explicit pairing (e.g. text-embedding-3-large@1024
	// in cloud, embeddinggemma@768 on a local/dev deployment).
	Dimensions uint

	// OrgID optionally scopes candidate listing and orphan pruning to
	// one tenant. Empty embeds every org.
	OrgID string
}

StoreConfig configures a span-embedding store.

Jump to

Keyboard shortcuts

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