openvibers/openvibe.search Commits

Hybrid search: a vector hit must be near enough to count (#143)

OP OpenVibers committed 14 days ago · 783ab6587b · 3 files, +33 −1

Every query returned its nearest vectors whatever their distance, so
"mechanical keyboard" answered with Pitch Changer and Audio Compressor
(0.39-0.43) and Vibe showed them as deals. Measured on production: real
matches sit at 0.15-0.23, the nearest document to an unrelated query at
0.34-0.55. Vector hits beyond SEARCH_VECTOR_MAX_DISTANCE (default 0.30)
are dropped before the fusion.

Changes

ddiff --git a/server/api/query.js b/server/api/query.js
iindex e7485c5..ac17eec 100644
---- a/server/api/query.js
++++ b/server/api/query.js
@@@ -173,7 +173,11 @@ function createSearcher({ config, store, engine, vectorStore = null, embeddings
vectorStore.nearest({ vector, filters, viewer, limit: HYBRID_CANDIDATES }),
]);
const snips = new Map(keyword.hits.map((h) => [h.rid, h.snip]));
-- return fuseRrf(keyword.hits, vectorHits).map((h) => ({ ...h, snip: snips.get(h.rid) }));
++ // A vector hit counts only when it is near enough to mean something (config.query.vectorMaxDistance); a query
++ // with no near document and no keyword hit answers nothing rather than its nearest strangers.
++ const floor = config.query.vectorMaxDistance;
++ const near = vectorHits.filter((h) => !(Number(h.distance) > floor));
++ return fuseRrf(keyword.hits, near).map((h) => ({ ...h, snip: snips.get(h.rid) }));
}
async function run({ text = '', query = {}, filters = null, viewer, limit, cursor, facetKeys = [], now = clock(), since = null }) {
ddiff --git a/server/config.js b/server/config.js
iindex 0c946c4..79bf8ca 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -95,6 +95,12 @@ function load(env = process.env) {
defaultLimit: 20,
maxTerms: 16,
maxFacetFilters: 8,
++ // The farthest a vector-only hit may be (cosine distance, bge-small-en-v1.5). Measured on production
++ // 2026-10-30: real matches sit at 0.15-0.23 ("merge pdf" -> Merge PDF 0.153), the nearest document to an
++ // unrelated query at 0.34-0.55 ("mechanical keyboard" -> Pitch Changer 0.392). Without a floor every query
++ // returned its five nearest tools, related or not. SEARCH_VECTOR_MAX_DISTANCE overrides.
++ vectorMaxDistance: Number.isFinite(Number(env.SEARCH_VECTOR_MAX_DISTANCE)) && env.SEARCH_VECTOR_MAX_DISTANCE !== '' && env.SEARCH_VECTOR_MAX_DISTANCE != null
++ ? Number(env.SEARCH_VECTOR_MAX_DISTANCE) : 0.30,
},
maxBodyBytes: int(env.SEARCH_MAX_BODY_BYTES, 256 * 1024),
ddiff --git a/test/hybrid.test.js b/test/hybrid.test.js
iindex 3cc4261..6e6da0a 100644
---- a/test/hybrid.test.js
++++ b/test/hybrid.test.js
@@@ -262,4 +262,26 @@ t('hybrid: keyword and vector top-100 are fused, ranks are per hit, and a hidden
}
});
++t('hybrid: a vector hit farther than the floor is dropped, so an unrelated query answers nothing (10-30)', async () => {
++ const vectors = new FakeVectors();
++ const embeddings = fakeEmbeddings();
++ const svc = await boot({ vectorStore: vectors, embeddingClient: embeddings });
++ try {
++ await svc.store.apply(doc({ id: 'pitch', title: 'Pitch Changer', body: 'shift the pitch of audio' }));
++ await svc.store.apply(doc({ id: 'merge', title: 'Merge PDF', body: 'join pdf files' }));
++ const rid = await ridMap(svc.db);
++ // What production measured for "mechanical keyboard": the nearest document is an audio tool at 0.392.
++ vectors.hits = [{ rid: rid.pitch, distance: 0.392 }, { rid: rid.merge, distance: 0.55 }];
++ let r = await request(svc.base, 'GET', '/api/v1/search?q=mechanical%20keyboard&limit=10');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(r.body.results, [], 'nothing near and no keyword hit: no results');
++ // A near vector hit still counts.
++ vectors.hits = [{ rid: rid.merge, distance: 0.153 }];
++ r = await request(svc.base, 'GET', '/api/v1/search?q=combine%20documents&limit=10');
++ assert.deepStrictEqual(r.body.results.map((x) => x.id), ['merge']);
++ } finally {
++ await svc.stop();
++ }
++});
++
t.run();