Vectors
ivyx✓
Turns your files into a searchable index that Knowledge and semantic search build on
Vectors
Turns your files into a searchable index and answers questions with the nearest passages from it. It has no panel of its own — the surfaces belong to the extensions built on top of it.
What you can do
- Get semantic search that finds a passage by meaning, not just by matching words
- Have it back the Knowledge catalog, the assistant's project context, the Semantic toggle in search, and capability matching in the generation pipeline
- Keep the whole thing on your machine by choosing an on-machine embedder, or send text to a remote one when you would rather
- Rebuild an index incrementally as your files change, rather than from scratch
- Get an answer even when the index is missing or the embedder stopped responding, because the query falls back to keyword search and says so
There is no vector database to run and no RAG framework here. It produces and serves an index over your own material.
Getting started
Install it and it starts working through whatever consumes it — turn on the Semantic toggle in search, or open the Knowledge catalog. Choose your embedder in the Vectors settings first if you want everything to stay on this machine.
What is in this version
The embedder, the index and the query. vectors.embed with its configuration, a self-test that
runs the whole pipeline over one notebook, vectors.index.build with incremental rebuilds,
removal and per-corpus collections, and vectors.query. The design is in docs/vectors.md and
the ordering is in the fleet's WORK/2-vectors.md.
The query
vectors.query embeds a question and returns the nearest passages. It never fails into
nothing: with no index, no embedder, or an endpoint that stopped answering, it falls back to
workspace keyword search and comes back labelled keyword with the reason attached. Consumers
render one list and show the label. Keyword hits presented as semantic ones is the one way
retrieval can lie, so the mode is never inferred and the reason is never swallowed.
corpus takes a list, because a notebook's markdown was routed to the document corpus while its
code stayed in the code one, and asking for a single corpus would lose half of the same file. The
scores merge because every collection named belongs to the same embedding space by construction.
There is no default score floor. Cosine values from hash and from a real model are not on the
same scale, so a threshold tuned on one is wrong on the other. Pass minScore if you have your
own evidence.
Each hit carries the whole passage in text and a capped one-line preview. An assistant putting
a hit in front of a model needs the passage; a truncation is not recoverable.
Two consumers ship with it. A ≈ toggle in the search panel, which is a mode of the existing
search box rather than a second panel. And capability matching in the generation pipeline, where
node documents and step queries are embedded in one batch up front so the matcher's inner loop
stays arithmetic, and where the scorer stays off on the hash embedder because a spurious
similarity there can only promote a wrong node.
The index
Call vectors.index.build with a corpus name and the paths that belong to it. That list is the
corpus: unchanged files are skipped, and files it no longer names have their chunks deleted. Dead
passages that survive a rebuild come back as results, so removal is part of building rather than a
separate clean action. Pass prune: false for a partial build, and know that it stops.
Notebooks split by cell. Everything else gets an overlapping line window, so a passage straddling a boundary is still reachable from one side.
A notebook's markdown cells do not compete with its code cells. They go to the document corpus instead. That is measured, not stylistic: with everything in one ranking, a two-line LaTeX note was the top hit for two unrelated queries, and separating them took retrieval from 3/5 to 4/5.
A build writes once per collection. The default store rewrites a whole collection on every
write, so writing per file costs 2.4 GB where one flush costs 11.8 MB, for the same index. That is
what npm run measure:vectors reports, and it is why no vector database is involved.
Embedders
Configured in Settings, under Vectors.
| Source | What it does | What it costs |
|---|---|---|
hash |
Deterministic on-machine hashing. The default | Free and offline. Matches on shared words, not on meaning, so it proves the pipeline runs rather than that retrieval works |
endpoint |
Posts the text to a URL you configure | Real quality. Every call sends your material off this machine |
local |
Runs a real embedding model on this machine | Real quality with no network. Needs the desktop app and a model runtime extension |
local is the only setting under which the index never leaves the machine and the results are
still semantic. Getting there is two steps: install the Ollama Runtime extension, then
ollama pull nomic-embed-text (or call llms.ollama.model.pull). Set the model name and 768 as
the dimension and it is on. There is no address to configure and no token, because nothing is
sent anywhere.
An endpoint is expected to answer with a list of vectors, { data: [{ embedding }] } or
{ embeddings: [...] }. Its token is named, not pasted: put the value in the SECRETS panel and
write the entry's name in vectors.endpoint.secretRef.
Sending text off the machine
A remote embedder turns "index my workspace" into sending the workspace somewhere. So:
- The address is shown before the first send of a session, with how many texts and how many characters are going. Changing the address asks again.
- The default embedder is never remote.
- Nothing is embedded automatically. Not on save, not on drop. Ingestion is always something you asked for.
vectors.embedder.statusreports the address without sending anything.
Embedder identity
Vectors from two different models are not comparable, and cosine similarity will not tell you that
— it returns confident nonsense. So identity is part of the address: a collection is named
<corpus>-<modelId>-<dim>. Changing the model or the vector length reads and writes a different
collection, the old vectors stay on disk until you clear them, and no query can cross embedding
spaces.
The configured dim is checked against the first vector that comes back. A mismatch is an error,
because it means the settings describe a different model than the one answering.
Capabilities
| Id | What it does |
|---|---|
vectors.embed |
Embed a batch of texts. The only embedding path in the product |
vectors.chunk |
Split a file into the units that get embedded |
vectors.index.build |
Chunk, embed and store a set of files |
vectors.index.upsert |
Store records a caller produced itself |
vectors.index.status |
What is indexed, per corpus, with bytes on disk |
vectors.query |
Embed a question and return the nearest passages, labelled by mode |
vectors.index.clear |
Drop an index |
vectors.embedder.status |
Which embedder, which space, which address. Sends nothing |
vectors.embedder.test |
One real call, to see whether the endpoint answers |
vectors.probe |
The self-test below |
Every signature is plural. A capability call traverses the whole gateway pipeline, so embedding one chunk at a time would put governance in an inner loop.
The self-test
vectors.probe reads one notebook, treats each cell as a chunk, embeds them, stores them in a
throwaway collection, asks a question worded unlike the cell that answers it through
vectors.query, and reports whether the right cell came back. Then it clears the collection. It
goes through the shipped query rather than around it, because a self-test that reimplements the
path it tests only tests the reimplementation.
It runs the same words through keyword search as a control. Without that, "semantic retrieval found
it" is an assertion; with it, it is a comparison. On the hash embedder the result says plainly
that the run proved the pipeline works and nothing about meaning — and it means that literally. On
one notebook hash passed a paraphrase query because two unrelated tokens hashed into the same
bucket, beating the cell that actually shared a word with the query. A single probe run on hash
is not evidence in either direction.
Three commands, three different questions:
npm run check:vectorsdrives the shipped modules in Node against a stubbed platform and a real loopback server. 153 assertions over the manifest, the embedder, the resolution point, the endpoint client, the on-machine source, the egress disclosure, the chunker, the builder, the query and the probe. It answers is the code the shape it claims to be.npm run probe:vectorsruns the money test against a live engine over this repo's own notebooks. It answers does a real model retrieve on meaning, and nothing stubbed can. It also runs the same model through thelocalsource, using the runtime extension's own build output rather than a stub, so the two columns are expected to report the same number.npm run measure:vectorsruns the write-cost measurement. It answers does the default store run out, which decides whether a vector database is ever worth adding.
Vectors: Run Retrieval Probe
path: notebooks/features.ipynb
query: the cell that normalises the data
expect: StandardScaler
License
MIT