lexical and semantic engines return match location

Both search engine return the scores with a set of items
that can have a search snippet.
This commit is contained in:
Bogdan Abaev 2026-08-23 16:56:21 -07:00
parent 57b30b17ea
commit 100b7e1f52
9 changed files with 186 additions and 51 deletions

View file

@ -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) {

View file

@ -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<Map>} - itemID -> score (0-1, higher is more relevant)
* @return {Promise<Object>} - { 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

View file

@ -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]))

View file

@ -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<Map>} - itemID -> similarity score (higher is more similar)
* @return {Promise<Object>} - { 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<Object[]>} - [{ chunkIndex, score, text, outlinePath,
* startBlock, endBlock, pageLabel, position, sectionPart,
* sectionParts }], best first, ties broken by position in the text.

View file

@ -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<Object[]>} - [{ source, text, ranges, strength }],
* with ranges an array of [start, end) pairs into the excerpt's text
*/

View file

@ -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 () {

View file

@ -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;

View file

@ -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

View file

@ -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();