Search
⌘K opens the search palette from anywhere in Subspace. It runs one hybrid search
across page titles, node text, extracted file text, mail, and memory
items, and returns typed result rows (page / node / mem / file / mail) ranked by
a fusion of two independent retrieval arms.
Two arms, one query
Section titled “Two arms, one query”Full-text search over tsvector generated columns on kb.nodes.text_tsv and
kb.pages.title_tsv, GIN-indexed, ranked with ts_rank_cd (length-normalized,
rewards term density). Because the tsvector columns are GENERATED ALWAYS AS ... STORED, the lexical index updates in the same transaction as the write, by
construction, there is no reindex lag to reason about.
Cosine approximate nearest-neighbor search over search.embeddings.vec, a
halfvec(1536) column under a pgvector HNSW index. Candidates are
metadata-prefiltered by corpus kind (node, memory, file, mail) before the
ANN scan. This arm is what catches typo’d or loosely-worded queries the lexical arm
misses.
The top-k hits from each arm are merged with reciprocal-rank fusion (k=60): each
result’s score is 1 / (60 + rank) summed across the arms it appears in, then deduped by
target so a hit that scores well on both arms floats to the top. If the embedding call
fails (provider outage, missing API key), search silently degrades to lexical-only rather
than erroring.
The embeddings indexer
Section titled “The embeddings indexer”A dedicated outbox consumer keeps the vector arm current by reacting to writes rather than polling:
| Trigger | What gets chunked |
|---|---|
A kb-command touches a page |
Its changed nodes, one chunk per node (ref: node:<nodeId>); a node is tagged kind: memory when it carries props.memory. Deleted or emptied nodes are pruned from the index. |
| A file finishes upload | search.file_text (the extracted text) is chunked into roughly 1,000-character pieces (ref: file:<fileId>:<n>) after the file-text extractor runs. |
| Mail arrives | Subject, snippet, and body-blob text are embedded together as one chunk (ref: mail:<id>). |
Embeds are batched and upserted, so redelivering an outbox notice after a crash never double-indexes. Two cost controls keep this cheap in practice:
- Diffed to dirty nodes only:
search.embeddings.text_hashstores an md5 of the last embedded text; unchanged text under the same model skips the embedding API call entirely on the next pass. - Debounced per page: a burst of edits to the same page coalesces into one batched
embed call of the final text, instead of one call per keystroke-triggered command. The
window is
SUBSPACE_INDEX_DEBOUNCE_MS(0means synchronous indexing; the default is0underPROVIDERS=fakeand1500ms otherwise). A debounced index lost to a crash before it flushes heals itself on the page’s next edit, or on a full reindex, since everything the indexer produces is derived state.
Under PROVIDERS=fake (the default for tests and local sandboxes), embeddings come
from a deterministic hashed-character-trigram function: 256 live dimensions,
zero-padded to 1536, tagged with model fake-trigram-256.
Otherwise, embeddings come from OpenAI’s text-embedding-3-small via the AI SDK
(requires OPENAI_API_KEY). Queries filter by the model column, so fake and real
vectors are never compared against each other.
Rebuilding the index
Section titled “Rebuilding the index”Run subspace reindex (pnpm --filter @subspace/server reindex) to rebuild everything
derived from scratch: it re-extracts search.file_text from stored blobs, then truncates
and re-embeds every node, file, and mail chunk. Stop the server first, it boots its own
embedded Postgres the same way the server does. Because new outbox consumers start
reading from the current head rather than replaying history, run a reindex once after
pointing Subspace at a pre-existing corpus so search has something to serve immediately.
Asking instead of searching
Section titled “Asking instead of searching”When a query looks more like a question than a lookup, the palette adds an “Ask
/rag” row below the ranked results. Pressing ⇧↵ on that row files /rag <your query> on the page you currently have open and starts the same child-node agent run
there, streaming a sourced answer as new bullets instead of a results list. See
RAG for how the underlying /rag, /rag-pageindex, and /rag-direct
agents retrieve and cite.
How agents search
Section titled “How agents search”Agents get the same retrieval machinery through dedicated tools rather than the ⌘K UI:
kb.search runs the identical hybrid RRF query (optionally filtered to node, memory,
file, or mail kinds) and returns up to 10 ranked hits as text; kb.grep complements
it with an exhaustive exact-or-regex scan over every bullet for “every occurrence of X”
queries that ranked retrieval can’t answer completely; kb.read (and the node-scoped
kb.node.read) fetch full page or subtree content once a search has narrowed down a
target. See the full agent tools reference for every tool’s
schema and approval behavior.