openvibers/openvibe.search Commits
Hybrid search: a vector hit must be near enough to count (#143)
Changes
@@@ -173,7 +173,11 @@ function createSearcher({ config, store, engine, vectorStore = null, embeddingsvectorStore.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 }) {@@@ -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),@@@ -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();
@@ -173,7 +173,11 @@ function createSearcher({ config, store, engine, vectorStore = null, embeddings
@@ -173,7 +173,11 @@ function createSearcher({ config, store, engine, vectorStore = null, embeddings
vectorStore.nearest({ vector, filters, viewer, limit: HYBRID_CANDIDATES }),
vectorStore.nearest({ vector, filters, viewer, limit: HYBRID_CANDIDATES }),
]);
]);
const snips = new Map(keyword.hits.map((h) => [h.rid, h.snip]));
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 }) {
async function run({ text = '', query = {}, filters = null, viewer, limit, cursor, facetKeys = [], now = clock(), since = null }) {
@@ -95,6 +95,12 @@ function load(env = process.env) {
@@ -95,6 +95,12 @@ function load(env = process.env) {
defaultLimit: 20,
defaultLimit: 20,
maxTerms: 16,
maxTerms: 16,
maxFacetFilters: 8,
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),
maxBodyBytes: int(env.SEARCH_MAX_BODY_BYTES, 256 * 1024),
@@ -262,4 +262,26 @@ t('hybrid: keyword and vector top-100 are fused, ranks are per hit, and a hidden
@@ -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();
t.run();