AkurAI Build
Menu

Public repository / master

AkurAI-Framework / site/content/docs/semantic-search.md

Latest change 7e388dfdbf1807a94cf3959541a06d8b3daf4fb5 - Add bearer auth support for embeddings by Ólafur Búi Ólafsson

# 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`).
2. It embeds the query string into a vector.
3. It compares that query vector against each record's stored embedding using
   **cosine similarity**.
4. It returns the **top-k** records by descending similarity.

```text
?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

```rust
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`.

```rust
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 `f32`s), 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:

```json
{ "model": "<model>", "input": "<text>" }
```

or, for a batch:

```json
{ "model": "<model>", "input": ["<text>", "<text>"] }
```

and parse the response shape:

```json
{ "data": [ { "embedding": [0.1, 0.2, ...] }, ... ] }
```

```rust
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.