HarperFast / HarperFast/harper
$distance omitted (undefined) when a cosine-sort search() result set is a singleton
- 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
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