HarperFast / HarperFast/harper

$distance omitted (undefined) when a cosine-sort search() result set is a singleton

Open
#1,699 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
JavaScript
Stars
89
Forks
10
Avg merge
2d 6h
Merged PRs (30d)
200

Description

## Summary

A cosine-sort `search()` query (`sort: { attribute, target, distance: "cosine" }`) with `$distance` in `select` returns `$distance: undefined` on the top (and only) record whenever the query's post-filter result set contains **exactly one** matching row. The moment a second matching record exists, `$distance` is populated correctly on the very first query ever issued — no warm-up, no retry needed.

Sort *order* is unaffected (the correct record still comes back first) — only the numeric `$distance` annotation is missing, specifically and only in the singleton case.

## Observed

```js
const query = {
sort: { attribute: "embedding", target: queryVector, distance: "cosine" },
conditions: [
{ attribute: "agentId", comparator: "equals", value: agentId },
{ attribute: "archived", comparator: "not_equal", value: true },
],
select: ["id", "content", "$distance"],
limit: 1,
};
for await (const record of tables.Memory.search(query)) {
console.log(record.$distance); // undefined, iff this agentId currently has exactly 1 matching row
}
```

We falsified two working theories before isolating the real trigger:

- **Not** an HNSW per-key "cold-start" — retrying the identical query 8× with 300ms delays (2.4s total) against a genuinely-singleton candidate set never recovered a `$distance`.
- **Not** a query-shape issue — wrapping conditions in `{operator: "or"}`, raising `limit` past 1, or changing the conditions shape doesn't change the result.

The one variable that flips it: whether the query's post-filter result set has exactly 1 row (`$distance` undefined) vs. ≥2 rows (`$distance` populated on every row, including the first).

Also observed while isolating this: selecting the sort attribute itself (`select: [..., "embedding"]`) on a sort-by-`embedding` query returns a bare scalar rather than the stored vector — Harper appears to special-case the sort attribute in `select`. Worth a look alongside the `$distance` gap, though it's the `$distance` omission that actually breaks scoring logic downstream.

## Impact

Any consumer that does `const cosine = 1 - (record.$distance ?? 1)` (a natural read given `$distance` is normally a real number) silently gets `cosine = 0` — a "definitely not a match" sentinel — on exactly the case that's most likely to be a near-duplicate: an agent's *second-ever* record compared against its first.

## Workaround (currently carried, would like to retire)

When `$distance` comes back `undefined`, fall back to a point-lookup of the one candidate by id (unaffected by this quirk — it's not a sort query) and compute cosine similarity manually in JS from its stored embedding. Duplicated in two call sites (a dedup gate and a semantic-search scorer) because both independently hit the same gap.

## Expected

`$distance` (or whatever field carries computed sort-distance) should be populated consistently regardless of result-set cardinality, including the singleton case.

## Environment

- `@harperfast/harper` 5.1.15 (npm), reproduced via native Node (not Docker)
- HNSW-indexed vector attribute, cosine distance

## Related

Relates to the #1659 epic (Vector/HNSW hardening — bugs), which already tracks general distance-ordering/ANN gaps. Adjacent to #775 (return distance-ordered results as a first-class API) — that issue proposes `$distance`/`_distance` as new surface; this report is that the existing `$distance` field already works for multi-row results and specifically breaks on singleton sets. Also touches the embeddings/models subsystem epic (#1684).

Contributor guide

Open the contributing guide

Research direction

Start by reproducing the issue through tables.Memory.search with cosine sorting, a singleton post-filter result, and $distance in select; compare it with a two-row result. Trace the vector-search result annotation path and the related #1659 and #1684 work. Done means the singleton record receives the computed numeric $distance consistently, with the existing workaround no longer needed.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, nodejs
Domain
databases, search
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.