Semantic search
The akurai-vector crate provides the primitives behind ?search=<text>: the vector math, a byte codec for storing embeddings, and a small embedding client that talks to any OpenAI-compatible endpoint. Like every other crate in the framework it is pure std — its only dependency is the workspace's own akurai-json.
It owns the math and the embedding fetch. It does not wire HTTP routes or touch collections — the CLI drives it.
How ?search ranks records
- The CLI reads the embedding endpoint and model from the environment
(AKURAI_EMBED_URL, AKURAI_EMBED_MODEL).
- It embeds the query string into a vector.
- It compares that query vector against each record's stored embedding using
cosine similarity.
- It returns the top-k records by descending similarity.
?search=text ──▶ embed(query) ──▶ cosine vs. each stored record embedding ──▶ top-k
Substring fallback
When no endpoint is configured (AKURAI_EMBED_URL is unset), the CLI skips embeddings entirely and falls back to plain substring search over the records. That fallback lives in the CLI, not in this crate — akurai-vector is config-free and never assumes a host.
Vector math
use akurai_vector::{cosine, rank};
// Cosine similarity, in [-1.0, 1.0].
let s = cosine(&[1.0, 0.0], &[1.0, 0.0]); // 1.0
// Top-k by descending cosine. Returns (id, score) pairs.
let query = vec![1.0, 0.0];
let candidates = vec![
(10u64, vec![0.0, 1.0]), // orthogonal -> 0.0
(20u64, vec![1.0, 0.0]), // identical -> 1.0
];
let top = rank(&query, &candidates, 1); // [(20, 1.0)]
cosine never panics. Documented edge cases:
| Case | Result | |------|--------| | Identical vectors | 1.0 | | Orthogonal vectors | 0.0 | | Opposite vectors | -1.0 | | Either vector zero-norm (all zeros / empty) | 0.0 | | Mismatched lengths | 0.0 (treated as not comparable) |
rank is stable: ties keep their input order. k larger than the candidate count returns all candidates; k == 0 returns an empty list.
Byte encoding for storage
Embeddings are stored as opaque byte blobs in the B+tree. The codec is a fixed little-endian f32 layout (4 bytes per component), converted explicitly so a vector round-trips exactly on any host — no unsafe, no transmute.
use akurai_vector::{encode, decode};
let v = vec![0.1_f32, -0.2, 0.3];
let bytes = encode(&v); // 12 bytes, little-endian
let back = decode(&bytes); // Some(vec![0.1, -0.2, 0.3])
assert_eq!(back, Some(v));
decode returns None if the blob length isn't a multiple of 4 (i.e. not a whole number of f32s), so a corrupt or short blob can never panic the search path.
The embedding protocol (OpenAI-compatible)
embed / embed_many POST to <endpoint>/v1/embeddings with the body:
{ "model": "<model>", "input": "<text>" }
or, for a batch:
{ "model": "<model>", "input": ["<text>", "<text>"] }
and parse the response shape:
{ "data": [ { "embedding": [0.1, 0.2, ...] }, ... ] }
use akurai_vector::{embed, embed_many, embed_with_bearer};
let v = embed("http://localhost:8081", "embeddinggemma", "halló heimur")?;
let vs = embed_many("http://localhost:8081", "embeddinggemma", &["a", "b"])?;
let rv = embed_with_bearer(
"http://127.0.0.1:4219",
"intfloat/multilingual-e5-small",
"halló heimur",
"akr_...",
)?;
# Ok::<(), akurai_vector::EmbedError>(())
embed_many returns one vector per input, in order; an empty input slice returns an empty result without any network call.
Plain HTTP only — no TLS
The client is a minimal HTTP/1.1 implementation over std::net::TcpStream. It speaks plain HTTP only. std has no TLS and the framework will not pull a crate for it — TLS terminates at the edge (Caddy/nginx), and these embedding endpoints are local/edge services on plain HTTP. Passing an https:// URL returns a clear EmbedError::TlsUnsupported rather than silently failing.
The endpoint is parsed as http://host:port (port defaults to 80; any trailing path is ignored — the client always posts to /v1/embeddings). The request carries Host, Content-Type: application/json, an exact Content-Length, and Connection: close, and a 30-second read/write timeout is set so the search path fails fast instead of hanging.
Error handling
Every failure mode maps to a distinct, non-panicking EmbedError variant so the CLI can react precisely (and degrade to substring search):
| Variant | When | |---------|------| | InvalidEndpoint | Endpoint isn't http://host[:port] | | TlsUnsupported | An https:// URL was given | | Connect | TCP connect failed (refused, DNS, host down) | | Timeout | Read/write timed out | | Io | Other socket I/O error | | HttpStatus { code, snippet } | Server replied non-2xx | | ShortResponse | Empty/truncated response, no header/body split | | MalformedJson | Body wasn't valid JSON | | UnexpectedShape | JSON didn't match {"data":[{"embedding":[...]}]} | | CountMismatch | Returned a different number of embeddings than requested |
Testing
The pure parts are factored out and fully unit-tested without ever touching the network: build_request, parse_endpoint, split_response, and parse_embeddings_response are checked against hand-written request/response strings, alongside the cosine / rank math and the encode / decode round-trip. The actual TcpStream round-trip (fetch) is the only part that is not unit-tested, by design.