All docs

Docs · Data & state

Vectors

semantic retrieval as operations

Thanks, Computer ships a durable, tenant-scoped vector store: a place to keep embeddings and find nearest neighbours, reached from txcl as ordinary operations. Producing the vectors is ai://embed's job; storing and searching them is txco://vector/*. The split is deliberate — embedding belongs to AI, storage belongs to infrastructure.

The whole loop is three ops: embed a piece of text, upsert the vector, and later search for the nearest matches to a query.

# 1. Embed and store a document (once, when it arrives).
WITH provider = "openai", model = "text-embedding-3-small",
     text = "Winnie-the-Pooh: gentle adventures in the Hundred Acre Wood"
EXEC "ai://embed"

WITH collection = "books",
     id         = "pooh",
     vector     = @_embed.vector,
     metadata   = &object("audience", "children", "public_domain", true),
     text       = "Winnie-the-Pooh: gentle adventures in the Hundred Acre Wood"
EXEC "txco://vector/upsert"
# 2. Later: embed a query and find the closest books.
WITH provider = "openai", model = "text-embedding-3-small",
     text = @web.req.body.query
EXEC "ai://embed"

WITH collection = "books",
     vector     = @_embed.vector,
     limit      = 5,
     into       = "._hits"
EXEC "txco://vector/search"

The matches land at the path you name with into (default _vector.matches), each with {id, score, distance, metadata, text} — score is the normalised similarity (higher = closer), ready for a reranker or a reply template.

Collections

A collection pins a vector space: an embedding model, a dimension count, and a distance metric (cosine, in v1). Vectors are only comparable inside one, so the store rejects an upsert whose dimensions don’t match — a model swap is a new collection plus a re-embed, never a silent mix.

Collections are created on first use, or explicitly:

WITH collection      = "books",
     embedding_model = "text-embedding-3-small",
     dimensions      = 1536,
     metric          = "cosine"
EXEC "txco://vector/collection"

Collections are tenant-scoped and shared across a tenant’s stacks (unlike a KV namespace, which defaults to the stack). The tenant is taken from the trusted request scope, never from the envelope.

Searching

txco://vector/search takes the query vector, a limit, and an optional metadata filter pushed into the store before ranking — so a tight filter both narrows results and dodges the “nearest 10, but only 2 pass the filter” trap:

WITH collection = "books",
     vector     = @_embed.vector,
     limit      = 3,
     filter     = &object("public_domain", true,
                          "id", &object("not_in", @._already_read)),
     into       = "._hits"
EXEC "txco://vector/search"

A filter value is matched against item metadata: a scalar means eq, an array means in, and an object like {"not_in": [...]} / {"gte": 12} selects the operator (eq, in, not_in, gte, lte, gt, lt). The special field id filters the item id itself — the idiomatic way to exclude already-seen results. The other ops are txco://vector/upsert (one item, or items = [ … ] in bulk — and in bulk the items may omit their vector when a parallel vectors = [ … ] supplies one per item by index, so a stack can assemble ids, metadata and text in a small sandboxed op and hand ai://embed’s result straight to the store without the floats ever riding through the sandbox; a count mismatch is txco_vector_invalid_arg) and txco://vector/delete (ids = [ … ]). Errors surface at vector.error.

Re-labelling without re-embedding

txco://vector/update merges merge into the metadata of every item that matches filter, leaving vectors and text alone — the way to rename a label or reclassify a document without paying for embeddings again:

WITH collection = "books",
     filter     = &object("audience", "guest"),
     merge      = &object("audience", "anyone", "legacy", null)
EXEC "txco://vector/update"
#   _vector.updated = the number of items matched

filter is the search grammar above and must name at least one condition (an unconditional rewrite is refused). merge is a shallow merge patch: each key replaces the item’s, a null removes the key, every other key is kept. The count lands at into (default _vector.updated), so several updates in one scope can keep their counts apart.

Deploying data: code vs. data

A search collection is usually a known set that belongs with the deploy — a product catalog, a knowledge base — not something you POST in by hand. Thanks, Computer treats that data declaratively, in a reserved tree beside OPS/ and FILES/:

OPS/<stack>/
  0100_…/…            # operations  ─┐
  FILES/…             # static assets │ code
  VECTORS/books.jsonl # a collection ─┐
  KV/config.jsonl     # a namespace   │ data
  BLOBS/faqs/a.md     # named blobs   │  (see advanced/blobs.md)
  CALENDARS/paris@pony.example.com/events.jsonl  # a calendar   │ (see advanced/protocols/calendar.md)
  CONTACTS/paris@pony.example.com/team.jsonl     # an address book ─┘ (see advanced/protocols/contacts.md)

Code and data deploy separately, and data is opt-in:

  • txco apply (and txco push) deploy code only — operations and FILES/. They never touch the stores, and they carry any existing VECTORS/ + KV/ packs forward untouched. A teammate can check out the repo without the big data packs, fix an operation, and txco apply — the live catalog is undisturbed.
  • txco data apply deploys the VECTORS/ + KV/ packs: it carries the stack’s code forward, replaces the data, and reconciles it into the stores. (Deploy code first — a stack must already have an active version.)
txco apply         # ship an operation change; data left alone
txco data apply    # ship the catalog; reconciled into the vector + KV stores

Reconcile is change-driven: only packs whose contents actually changed since the last deploy touch the store, so a code deploy does zero data work and a data re-apply only re-embeds what moved. Within a pack the store is synced to match (an id you removed from books.jsonl is deleted from the collection) — so keep a seeded collection pure: don’t also write to it at runtime.

The pack format

A VECTORS/<collection>.jsonl pack is NDJSON, one item per line, with pre-computed vectors — apply stays offline and deterministic, so the vectors are embedded at build time and serialized into the pack:

{"id":"pooh","vector":[0.0123,-0.0456, …],"metadata":{"public_domain":true},"text":"…","model":"text-embedding-3-small"}

The optional model field pins the collection’s embedding model, so the query path (ai://embed with the same model) stays comparable. A KV/<namespace>.jsonl pack is the same idea for key-value seed data: {"key":"…","value":<any JSON>,"ttl":<seconds?>}.

Note

A seeded KV/ namespace is synced to match its pack, so it must be a namespace no runtime op writes — never a stack’s default namespace (which is the stack name). Keep seed config in a dedicated namespace like KV/config.jsonl. Namespaces starting with _txc are reserved for the chassis and refused.

A BLOBS/ tree is the third data kind — one file per blob, the tree as the hierarchy, streamed rather than inlined. It rides the same txco data apply and is described in Blobs.

Inspecting and tearing down

The txco data verbs show what’s live and what a re-apply would change:

txco data ls                       # collections, with model / dims / item count
txco data show books               # one collection's pin + item ids
txco data diff books VECTORS/books.jsonl   # what `data apply` would add / remove
txco data rm books --yes           # drop a whole collection (explicit; apply never does this)

Removing a pack file from your tree stops managing its collection — it does not delete it. Tearing a collection down is the deliberate txco data rm.

Edit this page · View as markdown