mirror of
https://github.com/zotero/zotero.git
synced 2026-08-28 05:25:31 +00:00
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:
parent
57b30b17ea
commit
100b7e1f52
9 changed files with 186 additions and 51 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue