From 100b7e1f52764cfafb3edb920169a80bc30de0d3 Mon Sep 17 00:00:00 2001 From: Bogdan Abaev Date: Sun, 23 Aug 2026 16:56:21 -0700 Subject: [PATCH] lexical and semantic engines return match location Both search engine return the scores with a set of items that can have a search snippet. --- .../content/zotero/collectionViewItemTree.jsx | 4 +- chrome/content/zotero/xpcom/bestMatch.js | 44 ++++++++++--- chrome/content/zotero/xpcom/data/search.js | 2 +- chrome/content/zotero/xpcom/embeddings.js | 26 ++++++-- chrome/content/zotero/xpcom/lexical.js | 3 +- test/tests/bestMatchTest.js | 39 +++++++++--- test/tests/collectionViewItemTreeTest.js | 52 +++++++++------- test/tests/embeddingsTest.js | 62 +++++++++++++++++-- test/tests/searchTest.js | 5 +- 9 files changed, 186 insertions(+), 51 deletions(-) diff --git a/chrome/content/zotero/collectionViewItemTree.jsx b/chrome/content/zotero/collectionViewItemTree.jsx index 3396ed3039..f224e09384 100644 --- a/chrome/content/zotero/collectionViewItemTree.jsx +++ b/chrome/content/zotero/collectionViewItemTree.jsx @@ -271,11 +271,11 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider { let scores; let generation = this._bestMatchGeneration; try { - scores = await Zotero.BestMatch.scoreItemIDs(query, [...itemsByID.keys()], { + ({ scores } = await Zotero.BestMatch.scoreItemIDs(query, [...itemsByID.keys()], { // A newer filter (e.g. more typed search text) makes this // query obsolete -- stop scoring and let its refresh take over shouldCancel: () => generation !== this._bestMatchGeneration - }); + })); } catch (e) { if (e instanceof Zotero.BestMatch.ScoringCancelledError) { diff --git a/chrome/content/zotero/xpcom/bestMatch.js b/chrome/content/zotero/xpcom/bestMatch.js index e67467c927..6adcf5e72b 100644 --- a/chrome/content/zotero/xpcom/bestMatch.js +++ b/chrome/content/zotero/xpcom/bestMatch.js @@ -94,7 +94,14 @@ Zotero.BestMatch = new function () { * @param {Object} [options] * @param {Function} [options.shouldCancel] - Checked between scoring * stages; return true to abandon scoring with a ScoringCancelledError - * @return {Promise} - itemID -> score (0-1, higher is more relevant) + * @return {Promise} - { scores, matches }: scores maps + * itemID -> score (0-1, higher is more relevant); matches says which + * items each engine can show match excerpts in, as { lexical, + * semantic } Sets of itemIDs (see getMatchingExcerpts()). Every + * lexical match has excerpts to show; a semantic match does only + * when a previewable chunk carries it (see + * Zotero.Embeddings.scoreItemIDs()). An engine that didn't rank + * contributes an empty Set. * @throws {Zotero.BestMatch.ScoringCancelledError} */ this.scoreItemIDs = async function (queryText, itemIDs, options = {}) { @@ -104,16 +111,23 @@ Zotero.BestMatch = new function () { let engine = Zotero.Prefs.get('search.bestMatchEngine'); if (engine == 'semantic') { let semantic = await Zotero.Embeddings.scoreItemIDs(queryText, itemIDs, options); - // On the model's display band, so scores are 0-1 like the - // other modes' - return new Map([...semantic].map( - ([itemID, score]) => [itemID, Zotero.Embeddings.getScoreFraction(score)] - )); + return { + // On the model's display band, so scores are 0-1 like the + // other modes' + scores: new Map([...semantic.scores].map( + ([itemID, score]) => [itemID, Zotero.Embeddings.getScoreFraction(score)] + )), + matches: { lexical: new Set(), semantic: semantic.previewableIDs } + }; } // A query the semantic engine can't embed ranks lexically alone if (engine == 'lexical' || !_useSemantic() || !Zotero.Embeddings.normalizeQuery(queryText || '')) { - return await Zotero.Lexical.scoreItemIDs(queryText, itemIDs, options); + let scores = await Zotero.Lexical.scoreItemIDs(queryText, itemIDs, options); + return { + scores, + matches: { lexical: new Set(scores.keys()), semantic: new Set() } + }; } // Both engines score the same candidates concurrently. allSettled // rather than all, so one engine's failure still leaves the @@ -129,11 +143,23 @@ Zotero.BestMatch = new function () { if (semantic.reason instanceof Zotero.Embeddings.IndexNotReadyError) { Zotero.debug("Semantic index not ready -- ranking lexically: " + semantic.reason.message); - return lexical.value; + return { + scores: lexical.value, + matches: { + lexical: new Set(lexical.value.keys()), + semantic: new Set() + } + }; } throw semantic.reason; } - return _fuse(lexical.value, semantic.value); + return { + scores: _fuse(lexical.value, semantic.value.scores), + matches: { + lexical: new Set(lexical.value.keys()), + semantic: semantic.value.previewableIDs + } + }; } catch (e) { if (e instanceof Zotero.Embeddings.ScoringCancelledError diff --git a/chrome/content/zotero/xpcom/data/search.js b/chrome/content/zotero/xpcom/data/search.js index 5e468ad659..8048b630ca 100644 --- a/chrome/content/zotero/xpcom/data/search.js +++ b/chrome/content/zotero/xpcom/data/search.js @@ -836,7 +836,7 @@ Zotero.Search.prototype.search = async function (asTempTable) { // items list and membership is untouched. let bestMatch = this.getBestMatchQuery(); if (ids && ids.length && bestMatch && bestMatch.topK) { - let scores = await Zotero.BestMatch.scoreItemIDs(bestMatch.query, ids); + let { scores } = await Zotero.BestMatch.scoreItemIDs(bestMatch.query, ids); ids = [...scores.entries()] // Deterministic order: by score, then by itemID for equal scores .sort((a, b) => (b[1] - a[1]) || (a[0] - b[0])) diff --git a/chrome/content/zotero/xpcom/embeddings.js b/chrome/content/zotero/xpcom/embeddings.js index a133eaba79..921b17135f 100644 --- a/chrome/content/zotero/xpcom/embeddings.js +++ b/chrome/content/zotero/xpcom/embeddings.js @@ -1101,12 +1101,19 @@ Zotero.Embeddings = new function () { * @param {Function} [options.shouldCancel] - Checked between chunks; * return true to abandon scoring with a ScoringCancelledError (e.g. * because a newer query made this one obsolete) - * @return {Promise} - itemID -> similarity score (higher is more similar) + * @return {Promise} - { scores, previewableIDs }: scores maps + * itemID -> similarity score (higher is more similar); + * previewableIDs holds the scored itemIDs with at least one + * above-floor chunk that stores source references its text can be + * re-derived from (see getMatchingChunks()) -- the items whose + * match getMatchingChunks() can show. An item scored only by chunks + * without references is its own preview and isn't in the set. */ this.scoreItemIDs = async function (queryText, itemIDs, { shouldCancel } = {}) { let scores = new Map(); + let previewableIDs = new Set(); if (!itemIDs.length || !this.isEnabled()) { - return scores; + return { scores, previewableIDs }; } let calibration = await _requireReadyIndex(); let generation = _modelGeneration; @@ -1133,7 +1140,9 @@ Zotero.Embeddings = new function () { // Rows without an embedding are processed-but-empty markers (see // _setUpDB()), with nothing to score let rows = await Zotero.DB.queryAsync( - "SELECT itemID, embedding FROM embeddings.itemEmbeddings WHERE itemID IN (" + "SELECT itemID, embedding, " + + "(startBlock IS NOT NULL OR startOffset IS NOT NULL) AS previewable " + + "FROM embeddings.itemEmbeddings WHERE itemID IN (" + chunk.map(() => '?').join(',') + ") AND embedding IS NOT NULL", chunk ); @@ -1143,6 +1152,12 @@ Zotero.Embeddings = new function () { if (prev === undefined || dot > prev) { best.set(row.itemID, dot); } + // An above-floor chunk with source references makes its item's + // match showable; the chunk also puts the item's best at or + // above the floor, so the set stays within the returned items + if (dot >= minScore && row.previewable) { + previewableIDs.add(row.itemID); + } } } for (let [itemID, dot] of best) { @@ -1153,7 +1168,7 @@ Zotero.Embeddings = new function () { if (generation !== _modelGeneration) { throw new this.IndexNotReadyError('Model changed during scoring'); } - return scores; + return { scores, previewableIDs }; }; /** @@ -1176,7 +1191,8 @@ Zotero.Embeddings = new function () { * @param {String} queryText * @param {Number} itemID * @param {Object} [options] - * @param {Number} [options.limit=3] - Most chunks to return + * @param {Number} [options.limit=3] - Most chunks to return; Infinity + * for every chunk above the floor * @return {Promise} - [{ chunkIndex, score, text, outlinePath, * startBlock, endBlock, pageLabel, position, sectionPart, * sectionParts }], best first, ties broken by position in the text. diff --git a/chrome/content/zotero/xpcom/lexical.js b/chrome/content/zotero/xpcom/lexical.js index e280a08acd..b6df4dfa51 100644 --- a/chrome/content/zotero/xpcom/lexical.js +++ b/chrome/content/zotero/xpcom/lexical.js @@ -412,7 +412,8 @@ Zotero.Lexical = new function () { * @param {String} queryText * @param {Number} itemID * @param {Object} [options] - * @param {Number} [options.limit=5] - Most excerpts to return + * @param {Number} [options.limit=5] - Most excerpts to return; Infinity + * for every excerpt the item's matches cut into * @return {Promise} - [{ source, text, ranges, strength }], * with ranges an array of [start, end) pairs into the excerpt's text */ diff --git a/test/tests/bestMatchTest.js b/test/tests/bestMatchTest.js index 3506c7ce1a..b1efcfdedc 100644 --- a/test/tests/bestMatchTest.js +++ b/test/tests/bestMatchTest.js @@ -21,8 +21,12 @@ describe("Zotero.BestMatch", function () { if (lexical) { stubs.push(sinon.stub(Zotero.Lexical, 'scoreItemIDs').callsFake(lexical)); } + // Per-test semantic fakes return bare score Maps; wrap them in the + // engine's { scores, previewableIDs } envelope if (semantic) { - stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake(semantic)); + stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs') + .callsFake(async (...args) => ( + { scores: await semantic(...args), previewableIDs: new Set() }))); } } @@ -41,7 +45,7 @@ describe("Zotero.BestMatch", function () { lexical: async () => lexicalScores }); - let scores = await Zotero.BestMatch.scoreItemIDs('owl', [1, 2, 3]); + let { scores } = await Zotero.BestMatch.scoreItemIDs('owl', [1, 2, 3]); assert.isFalse(semanticStub.called); assert.deepEqual([...scores.entries()], [[1, 0.8], [2, 0.3]]); }); @@ -54,7 +58,7 @@ describe("Zotero.BestMatch", function () { semantic: async () => new Map([[2, 0.8], [3, 0.6]]) }); - let scores = await Zotero.BestMatch.scoreItemIDs('owl', [1, 2, 3, 4]); + let { scores } = await Zotero.BestMatch.scoreItemIDs('owl', [1, 2, 3, 4]); assert.sameMembers([...scores.keys()], [1, 2, 3]); assert.closeTo(scores.get(1), rrf([0.9, 1]), 1e-12); assert.closeTo(scores.get(2), rrf([0.5, 2], [0.8, 1]), 1e-12); @@ -73,7 +77,7 @@ describe("Zotero.BestMatch", function () { semantic: async () => new Map([[1, 0.9], [2, 0.05]]) }); - let scores = await Zotero.BestMatch.scoreItemIDs('owl', [1, 2]); + let { scores } = await Zotero.BestMatch.scoreItemIDs('owl', [1, 2]); // Appearing in both lists isn't worth much when both appearances // are weak: the contributions carry the engines' own fractions assert.isAbove(scores.get(1), scores.get(2)); @@ -87,7 +91,7 @@ describe("Zotero.BestMatch", function () { fraction: score => score / 2 }); - let scores = await Zotero.BestMatch.scoreItemIDs('owl', [1, 2]); + let { scores } = await Zotero.BestMatch.scoreItemIDs('owl', [1, 2]); // The raw similarity scores rank; their display-band fractions // (0.4 and 0.3) are what the contributions carry assert.closeTo(scores.get(1), rrf([0.4, 1]), 1e-12); @@ -101,7 +105,7 @@ describe("Zotero.BestMatch", function () { semantic: async () => new Map() }); - let scores = await Zotero.BestMatch.scoreItemIDs('owl', [1, 2, 3]); + let { scores } = await Zotero.BestMatch.scoreItemIDs('owl', [1, 2, 3]); assert.equal(scores.get(1), scores.get(2)); assert.closeTo(scores.get(3), rrf([0.4, 2]), 1e-12); }); @@ -116,8 +120,29 @@ describe("Zotero.BestMatch", function () { } }); - let scores = await Zotero.BestMatch.scoreItemIDs('owl', [1, 2]); + let { scores, matches } = await Zotero.BestMatch.scoreItemIDs('owl', [1, 2]); assert.deepEqual([...scores.entries()], [[1, 0.8], [2, 0.3]]); + // The engine that didn't rank shows matches in nothing + assert.equal(matches.semantic.size, 0); + assert.sameMembers([...matches.lexical], [1, 2]); + }); + + it("should report which items each engine can show matches in", async function () { + stubs.push(sinon.stub(Zotero.Embeddings, 'isEnabled').returns(true)); + stubs.push(sinon.stub(Zotero.Embeddings, 'getScoreFraction') + .callsFake(score => score)); + stubs.push(sinon.stub(Zotero.Lexical, 'scoreItemIDs') + .resolves(new Map([[1, 0.9]]))); + stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs').resolves({ + scores: new Map([[2, 0.8], [3, 0.7]]), + previewableIDs: new Set([2]) + })); + + let { matches } = await Zotero.BestMatch.scoreItemIDs('owl', [1, 2, 3]); + // Every lexical match has excerpts to show... + assert.sameMembers([...matches.lexical], [1]); + // ...while a semantic match shows only through previewable chunks + assert.sameMembers([...matches.semantic], [2]); }); it("should map either engine's cancellation to its own error", async function () { diff --git a/test/tests/collectionViewItemTreeTest.js b/test/tests/collectionViewItemTreeTest.js index ebfebd987b..ee7bac6090 100644 --- a/test/tests/collectionViewItemTreeTest.js +++ b/test/tests/collectionViewItemTreeTest.js @@ -245,6 +245,16 @@ describe("CollectionViewItemTree", function () { describe("in best-match mode", function () { var stubs = []; + // Embeddings scoreItemIDs fakes below supply bare score Maps (or a + // function returning one); wrap them in the engine's real + // { scores, previewableIDs } envelope + function scoreEnvelope(fake) { + return async (...args) => ({ + scores: await (typeof fake == 'function' ? fake(...args) : fake), + previewableIDs: new Set() + }); + } + beforeEach(function () { stubs.push(sinon.stub(Zotero.Embeddings, 'isEnabled').returns(true)); stubs.push(sinon.stub(Zotero.Embeddings, 'getScoreFraction').callsFake(score => score)); @@ -282,7 +292,7 @@ describe("CollectionViewItemTree", function () { let itemA = await createDataObject('item', { title: "A", collections: [col.id] }); let itemB = await createDataObject('item', { title: "B", collections: [col.id] }); let itemC = await createDataObject('item', { title: "C", collections: [col.id] }); - stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake(async (query, itemIDs) => { + stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake(scoreEnvelope(async (query, itemIDs) => { let scores = new Map(); if (itemIDs.includes(itemA.id)) { scores.set(itemA.id, 0.5); @@ -291,7 +301,7 @@ describe("CollectionViewItemTree", function () { scores.set(itemB.id, 0.9); } return scores; - })); + }))); await select(win, col); itemsView = zp.itemsView; @@ -356,7 +366,7 @@ describe("CollectionViewItemTree", function () { let other = await createDataObject('item', { title: "liftrank other", collections: [col.id] }); // Only the annotation and the unrelated peer match on their own // text -- the paper's own abstract says nothing about the query - stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake(async (query, itemIDs) => { + stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake(scoreEnvelope(async (query, itemIDs) => { let scores = new Map(); if (itemIDs.includes(annotation.id)) { scores.set(annotation.id, 0.9); @@ -365,7 +375,7 @@ describe("CollectionViewItemTree", function () { scores.set(other.id, 0.5); } return scores; - })); + }))); await select(win, col); itemsView = zp.itemsView; @@ -425,9 +435,9 @@ describe("CollectionViewItemTree", function () { it("should move the Relevance column to the far right and restore it when cleared", async function () { let col = await createDataObject('collection'); let item = await createDataObject('item', { title: "farright A", collections: [col.id] }); - stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake( + stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake(scoreEnvelope( async (query, itemIDs) => new Map(itemIDs.map(id => [id, 0.5])) - )); + ))); await select(win, col); itemsView = zp.itemsView; @@ -453,9 +463,9 @@ describe("CollectionViewItemTree", function () { let col = await createDataObject('collection'); let itemA = await createDataObject('item', { title: "persistsort A", collections: [col.id] }); let itemB = await createDataObject('item', { title: "persistsort B", collections: [col.id] }); - stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake( + stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake(scoreEnvelope( async (query, itemIDs) => new Map(itemIDs.map(id => [id, id == itemB.id ? 0.9 : 0.5])) - )); + ))); await select(win, col); itemsView = zp.itemsView; @@ -495,7 +505,7 @@ describe("CollectionViewItemTree", function () { let col = await createDataObject('collection'); let item = await createDataObject('item', { title: "A", collections: [col.id] }); stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs') - .resolves(new Map([[item.id, 0.7]]))); + .callsFake(scoreEnvelope(new Map([[item.id, 0.7]])))); // The counts are split between the item and attachment pairs, so // the banner's totals prove the two are summed Zotero.Embeddings.Indexing.getStatus.returns({ @@ -579,9 +589,9 @@ describe("CollectionViewItemTree", function () { let col2 = await createDataObject('collection'); let shared = await createDataObject('item', { collections: [col1.id, col2.id] }); let other = await createDataObject('item', { collections: [col2.id] }); - let scoreStub = sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake( + let scoreStub = sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake(scoreEnvelope( async (query, itemIDs) => new Map(itemIDs.map(id => [id, id == shared.id ? 0.9 : 0.5])) - ); + )); stubs.push(scoreStub); await cv.selectByID("C" + col1.id); @@ -607,11 +617,11 @@ describe("CollectionViewItemTree", function () { let itemB = await createDataObject('item', { title: "savedsimtest B" }); // Install the stub first: creating the saved search auto-selects // it, which already runs a best-match refresh - let stub = sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake( + let stub = sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake(scoreEnvelope( async (query, itemIDs) => new Map(itemIDs.map((id) => { let best = query == 'saved query' ? itemA.id : itemB.id; return [id, id == best ? 0.9 : 0.5]; - })) + }))) ); stubs.push(stub); let search = new Zotero.Search(); @@ -643,11 +653,11 @@ describe("CollectionViewItemTree", function () { // Install the stub first: creating the saved search auto-selects // it, which already runs its top-K search let scores = new Map([[kItem1.id, 0.9], [kItem2.id, 0.5], [colItem2.id, 0.7]]); - stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake( + stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake(scoreEnvelope( async (query, itemIDs) => new Map( itemIDs.filter(id => scores.has(id)).map(id => [id, scores.get(id)]) ) - )); + ))); let search = new Zotero.Search(); search.name = "Top-K best-match test"; search.libraryID = col.libraryID; @@ -699,9 +709,9 @@ describe("CollectionViewItemTree", function () { let itemA = await createDataObject('item', { title: "rerank A", collections: [col.id] }); let itemB = await createDataObject('item', { title: "rerank B", collections: [col.id] }); let best = itemA.id; - stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake( + stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake(scoreEnvelope( async (query, itemIDs) => new Map(itemIDs.map(id => [id, id == best ? 0.9 : 0.5])) - )); + ))); await select(win, col); itemsView = zp.itemsView; @@ -720,9 +730,9 @@ describe("CollectionViewItemTree", function () { let itemA = await createDataObject('item', { title: "norerank A", collections: [col.id] }); let itemB = await createDataObject('item', { title: "norerank B", collections: [col.id] }); let best = itemA.id; - stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake( + stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake(scoreEnvelope( async (query, itemIDs) => new Map(itemIDs.map(id => [id, id == best ? 0.9 : 0.5])) - )); + ))); await select(win, col); itemsView = zp.itemsView; @@ -741,9 +751,9 @@ describe("CollectionViewItemTree", function () { let itemA = await createDataObject('item', { title: "reuse A", collections: [col.id] }); let itemB = await createDataObject('item', { title: "reuse B", collections: [col.id] }); let best = itemA.id; - stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake( + stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake(scoreEnvelope( async (query, itemIDs) => new Map(itemIDs.map(id => [id, id == best ? 0.9 : 0.5])) - )); + ))); await select(win, col); itemsView = zp.itemsView; diff --git a/test/tests/embeddingsTest.js b/test/tests/embeddingsTest.js index 22522ac40d..b107a64b7d 100644 --- a/test/tests/embeddingsTest.js +++ b/test/tests/embeddingsTest.js @@ -139,7 +139,7 @@ describe("Zotero.Embeddings", function () { + "VALUES ('modelVersion', 'test-model/1')" ); try { - let scores = await Zotero.Embeddings.scoreItemIDs('anything', + let { scores } = await Zotero.Embeddings.scoreItemIDs('anything', [close.id, distant.id]); assert.isAbove(scores.get(close.id), 0.9); assert.isFalse(scores.has(distant.id)); @@ -183,7 +183,7 @@ describe("Zotero.Embeddings", function () { + "VALUES ('modelVersion', 'test-model/1')" ); try { - let scores = await Zotero.Embeddings.scoreItemIDs('anything', + let { scores } = await Zotero.Embeddings.scoreItemIDs('anything', [chunked.id, distant.id]); // The item scores as its best chunk, not an average, so the // unrelated first chunk doesn't dilute the match @@ -196,6 +196,60 @@ describe("Zotero.Embeddings", function () { }); }); + describe("#scoreItemIDs() previewable items", function () { + it("should report which items a previewable chunk carries", async function () { + let axis = (index, scale = 1) => { + let vector = Float32Array.from(testMean); + vector[index] += scale; + return vector; + }; + let store = async (item, chunkIndex, vector, { blocks = false } = {}) => { + let blob = new Uint8Array(vector.buffer, vector.byteOffset, vector.byteLength); + await Zotero.DB.queryAsync( + "REPLACE INTO embeddings.itemEmbeddings " + + "(itemID, chunkIndex, embedding, sourceHash, startBlock, endBlock) " + + "VALUES (?, ?, ?, 'hash', ?, ?)", + [item.id, chunkIndex, blob, blocks ? 0 : null, blocks ? 2 : null], + { debugParams: false } + ); + }; + // One item matches through a chunk with source references (plus a + // below-floor chunk with references, which must not count), one + // matches only through a chunk without them, and a distractor + // matches nothing + let chunked = await createDataObject('item'); + await store(chunked, 0, axis(2), { blocks: true }); + await store(chunked, 1, axis(0), { blocks: true }); + let plain = await createDataObject('item'); + await store(plain, 0, axis(0)); + let distant = await createDataObject('item'); + await store(distant, 0, axis(3)); + + let stubs = [ + sinon.stub(Zotero.Embeddings, 'isEnabled').returns(true), + sinon.stub(Zotero.Embeddings, 'getModelVersion').returns('test-model/1'), + sinon.stub(Zotero.Embeddings, 'embedQuery').resolves(axis(0)) + ]; + await Zotero.DB.queryAsync( + "REPLACE INTO embeddings.itemEmbeddingsMeta (key, value) " + + "VALUES ('modelVersion', 'test-model/1')" + ); + try { + let { scores, previewableIDs } = await Zotero.Embeddings.scoreItemIDs( + 'anything', [chunked.id, plain.id, distant.id]); + assert.isTrue(previewableIDs.has(chunked.id)); + // A match carried only by chunks without source references is + // its own preview + assert.isTrue(scores.has(plain.id)); + assert.isFalse(previewableIDs.has(plain.id)); + assert.isFalse(previewableIDs.has(distant.id)); + } + finally { + stubs.forEach(stub => stub.restore()); + } + }); + }); + describe("#getMatchingChunks()", function () { var axis = (index, scale = 1) => { let vector = Float32Array.from(testMean); @@ -1784,7 +1838,7 @@ describe("Zotero.Embeddings", function () { // A processed-but-empty item can't be scored, and doesn't // break scoring for anything else - let scores = await Zotero.Embeddings.scoreItemIDs('anything', [attachment.id]); + let { scores } = await Zotero.Embeddings.scoreItemIDs('anything', [attachment.id]); assert.isFalse(scores.has(attachment.id)); // The record makes later passes skip the attachment without @@ -1990,7 +2044,7 @@ describe("Zotero.Embeddings", function () { + "VALUES ('modelVersion', 'test-model/1')" ); try { - let scores = await Zotero.Embeddings.scoreItemIDs('anything', + let { scores } = await Zotero.Embeddings.scoreItemIDs('anything', [empty.id, distinct.id]); // Without centering the two would be nearly indistinguishable, // since both consist mostly of the shared direction diff --git a/test/tests/searchTest.js b/test/tests/searchTest.js index 5a9c963e9c..50d0115e68 100644 --- a/test/tests/searchTest.js +++ b/test/tests/searchTest.js @@ -244,7 +244,10 @@ describe("Zotero.Search", function () { // With a cutoff, membership is the K most relevant let stub = sinon.stub(Zotero.BestMatch, 'scoreItemIDs').callsFake( - async (query, itemIDs) => new Map(itemIDs.map(id => [id, id == itemB.id ? 0.9 : 0.5])) + async (query, itemIDs) => ({ + scores: new Map(itemIDs.map(id => [id, id == itemB.id ? 0.9 : 0.5])), + matches: { lexical: new Set(itemIDs), semantic: new Set() } + }) ); try { let s2 = new Zotero.Search();