Search tuning in the Aetherfy vector database
Before you tune an Aetherfy search
If your results are simply wrong — plausibly ranked but not relevant — this is almost certainly the wrong page. Query-time tuning trades recall against cost at the margin; it does not repair results that were never comparable in the first place. Rule these out first, in this order:
| Symptom | Likely cause | Where it is covered |
|---|---|---|
| Results look ranked but unrelated to the query | The query vector came from a different model, or different preprocessing, than the stored vectors | Bring your own embeddings |
| Ranking is subtly poor across the board | The collection’s distance metric is not the one your embedding model was trained for, and Aetherfy cannot detect that | Bring your own embeddings |
| The right rows are missing entirely | A filter is excluding them — including a misspelled condition key, which Aetherfy forwards and silently ignores | Filtering |
| Results are fine but you want a little more recall | Genuinely a tuning question | this page |
Only the last row is what follows. Tuning hnsw_ef upward on a collection whose
vectors and query come from different models makes the wrong answers arrive more
expensively.
What search tuning is in the Aetherfy vector database
Collection-level configuration in the Aetherfy vector database is fixed server-side: you cannot set index or optimizer parameters when you create a collection. What you can control is per-query behaviour, one search at a time, through a single pass-through option.
| SDK | Option | Type | Position |
|---|---|---|---|
| Python | search_params | Optional[Dict[str, Any]] | last parameter of search() |
| JavaScript | searchParams | Record<string, unknown> | field on SearchOptions |
Aetherfy sends the object verbatim as the request body’s params field. It is
added last, and only when present — omitting it produces a body with no params
key at all, which is not the same request as one carrying an empty object.
This works against every deployed Aetherfy backend. There is no version gate and no capability negotiation.
Passing search parameters through the Aetherfy SDKs
The option is available on the Aetherfy client’s search() in both languages.
import os
from aetherfy_vectors import AetherfyVectorsClient
client = AetherfyVectorsClient(api_key=os.environ["AETHERFY_API_KEY"])
results = client.search(
collection_name="articles",
query_vector=[0.1, 0.2, 0.3, 0.4],
limit=10,
search_params={"hnsw_ef": 256},
)
for hit in results:
print(hit.id, hit.score)
client.close()import { AetherfyVectorsClient } from 'aetherfy-vectors';
const client = new AetherfyVectorsClient({ apiKey: process.env.AETHERFY_API_KEY });
const results = await client.search('articles', [0.1, 0.2, 0.3, 0.4], {
limit: 10,
searchParams: { hnsw_ef: 256 },
});
for (const hit of results) {
console.log(hit.id, hit.score);
}
client.dispose();The equivalent request against the Aetherfy REST API puts the same object under
params:
curl -s -X POST https://vectors.aetherfy.com/api/v1/collections/articles/points/search \
-H "Authorization: Bearer $AETHERFY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"vector":[0.1,0.2,0.3,0.4],"limit":10,"params":{"hnsw_ef":256}}'Tuning the graph walk with hnsw_ef on Aetherfy
The headline use of the option on Aetherfy is hnsw_ef.
A larger ef makes the HNSW graph walk visit more candidates: better results, at
slightly higher cost per query. A smaller ef does the reverse — it visits
fewer candidates and finishes sooner, at the cost of result quality. There is no
universally correct value; it is a dial between quality and cost for your
workload.
| Value | Effect on an Aetherfy search |
|---|---|
| Larger than the default | Visits more candidates — better results, slightly higher cost |
| Omitted | Uses Aetherfy’s tuned server-side default of hnsw_ef=100 |
| Smaller than the default | Visits fewer candidates — cheaper, lower result quality |
Omit the option entirely unless you have measured a reason not to. The server-side default is tuned, and a hand-picked value that has not been compared against it is a guess.
Tuning memory-layer searches on Aetherfy
The same option is accepted by the Aetherfy memory layer, not just the raw vector
client. Namespace and Thread both expose a .search() that takes
search_params in Python and searchParams in JavaScript, with identical
pass-through semantics: the object goes to the request body’s params field
untouched.
A Thread.search() also carries the thread’s own payload filter, since every
thread in a workspace shares one collection. The tuning option is orthogonal
to that: hnsw_ef changes how many candidates the graph walk visits, not
which thread they can come from.
The guidance the Aetherfy SDKs give for this layer is worth repeating verbatim: recall matters here — retrieving the right memory usually beats saving a millisecond. If you are going to move the dial for memory retrieval, move it towards visiting more candidates, not fewer.
How Aetherfy’s response cache treats search parameters
Aetherfy derives its server-side cache key from the request body bytes. Because
the tuning object is part of the body, the same query issued at a different ef
is a separate cache entry.
Two consequences follow, and both are safe:
| Situation | Result on Aetherfy |
|---|---|
| Same query, same params | May be served from the cache entry stored under those params |
| Same query, different params | Cannot hit the entry stored under the other params — it is a different key |
Same query, params omitted vs {} present | Different bodies, therefore different keys |
A params-varying call can never be answered by an entry stored under different
params. There is no scenario in which Aetherfy returns you a result computed at
an ef you did not ask for. The cost of varying the dial is cache-entry
multiplication, not correctness.
What Aetherfy does not validate in the tuning object
Neither Aetherfy SDK inspects, validates, or translates the contents of the
tuning object. Key names, value types, and value ranges are owned by the Aetherfy
vector API and by Qdrant beneath it — the SDK’s only responsibility is to
serialise the object into params.
This is deliberate: enumerating engine parameter names in the client would make every SDK release a compatibility treadmill behind the engine’s own schema. The cost is that a key the engine does not recognise is not rejected by the SDK. What is rejected — loudly — is a misspelling of the SDK’s own option name, which is the next section.
How unknown options fail in the Aetherfy SDKs
Both Aetherfy SDKs refuse an unrecognised option before any HTTP request is made. Neither one silently drops it. This is the distinction that makes the pass-through design safe: engine parameters are opaque, but SDK option names are closed.
| SDK | Mechanism | What you get |
|---|---|---|
| Python | search() has no **kwargs sink | A native TypeError from the call itself; no request is sent |
| JavaScript | Runtime check of the options object’s keys | A TypeError naming the offending keys and listing the accepted ones; no request is sent |
The Aetherfy JavaScript error lists the accepted keys and tells you where the option should have gone:
search: unknown option(s): <names>. Accepted: limit, offset, queryFilter, withPayload, withVectors, scoreThreshold, searchParams. Engine-level search tuning goes in searchParams, e.g. { searchParams: { hnsw_ef: 256 } }.The check is not limited to search. Every JavaScript method that takes an
options object applies it, including the client constructor and create(),
retrieve, scroll, scrollIter, count, and the MemoryClient,
Namespace and Thread methods. A key is refused even when its value is
undefined.
The mistake these guards catch most often is an engine parameter passed as if it
were an SDK option. hnsw_ef=256 given straight to Python’s search() raises a
TypeError naming hnsw_ef, and hnsw_ef: 256 in the JavaScript search options
throws the message above. Neither sends a request. The engine parameter belongs
inside the tuning object:
import os
from aetherfy_vectors import AetherfyVectorsClient
client = AetherfyVectorsClient(api_key=os.environ["AETHERFY_API_KEY"])
# `hnsw_ef` is an engine parameter, so it goes in search_params.
results = client.search(
"articles",
query_vector=[0.1, 0.2, 0.3, 0.4],
search_params={"hnsw_ef": 256},
)
print(len(results))
client.close()import { AetherfyVectorsClient } from 'aetherfy-vectors';
const client = new AetherfyVectorsClient({ apiKey: process.env.AETHERFY_API_KEY });
// `hnsw_ef` is an engine parameter, so it goes in searchParams.
const results = await client.search('articles', [0.1, 0.2, 0.3, 0.4], {
searchParams: { hnsw_ef: 256 },
});
console.log(results.length);
client.dispose();Absent, null, and empty tuning objects on Aetherfy
The three states are not equivalent, and the difference is observable in Aetherfy’s cache-key behaviour.
| You pass | Aetherfy sends | Cache consequence |
|---|---|---|
| Nothing | No params key in the body | The untuned entry |
null or undefined | No params key in the body — identical to omitting it | The untuned entry |
{} | "params": {} — the key is sent | A distinct cache entry from the untuned one |
If you are building the tuning object conditionally, prefer leaving it undefined
over defaulting it to {}: the empty object costs you a second Aetherfy cache
entry for what is otherwise the same query.
Filter syntax, which travels in a different body field, is on
filtering; the full search() surface is on the SDK
reference.