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

  1. The CLI reads the embedding endpoint and model from the environment

(AKURAI_EMBED_URL, AKURAI_EMBED_MODEL).

  1. It embeds the query string into a vector.
  2. It compares that query vector against each record's stored embedding using

cosine similarity.

  1. 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.