From 2112da07663fa51c0d2069bd6d1fcb7c1ddd7c2d Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Fri, 24 Jul 2026 13:35:40 -0400 Subject: [PATCH] Rerank best-match only on embeddings-index updates The item tree reran the active best-match search on every 'refresh' item event, so unrelated bursts (e.g., full-text indexing) triggered a full re-search and re-score. Flag the embeddings indexer's own notifications and rerank only for those. --- .../content/zotero/collectionViewItemTree.jsx | 7 +++--- chrome/content/zotero/xpcom/embeddings.js | 9 ++++---- chrome/content/zotero/xpcom/notifier.js | 2 +- test/tests/collectionViewItemTreeTest.js | 23 ++++++++++++++++++- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/chrome/content/zotero/collectionViewItemTree.jsx b/chrome/content/zotero/collectionViewItemTree.jsx index 35599f474a..a55779a622 100644 --- a/chrome/content/zotero/collectionViewItemTree.jsx +++ b/chrome/content/zotero/collectionViewItemTree.jsx @@ -930,11 +930,10 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider { if (items.length == 0) return; } - if (action == 'refresh' && type == 'item' + if (action == 'refresh' && type == 'item' && extraData && extraData.embeddingsUpdate && collectionTreeRows.some(rowIsBestMatchSearch)) { - // Under an active best-match search, a refresh event can mean new or - // changed embeddings (the background indexer notifies after committing - // batches), so rerun the search to update the scores and ranks + // The background indexer committed new or changed embeddings, so + // rerun the active best-match search to update the scores and ranks this.itemTree.invalidateRowCache(ids); refresh = true; madeChanges = true; diff --git a/chrome/content/zotero/xpcom/embeddings.js b/chrome/content/zotero/xpcom/embeddings.js index b83accf108..1a93e70176 100644 --- a/chrome/content/zotero/xpcom/embeddings.js +++ b/chrome/content/zotero/xpcom/embeddings.js @@ -1245,9 +1245,10 @@ Zotero.Embeddings.Indexing = new function () { // Announce written or removed embeddings with a 'refresh' item event, so // an active best-match search reranks as vectors change (e.g. during - // initial indexing, or after a clear). Coalesced, so a long indexing run - // produces an update every couple of seconds rather than one per - // committed batch. + // initial indexing, or after a clear). The embeddingsUpdate flag lets the + // item tree rerank only for these events, not for every refresh. Coalesced, + // so a long indexing run produces an update every couple of seconds rather + // than one per committed batch. function _notifyIndexed(itemIDs) { for (let id of itemIDs) { _indexedNotifyIDs.add(id); @@ -1259,7 +1260,7 @@ Zotero.Embeddings.Indexing = new function () { _indexedNotifyTimer = null; let ids = [..._indexedNotifyIDs]; _indexedNotifyIDs.clear(); - Zotero.Notifier.trigger('refresh', 'item', ids) + Zotero.Notifier.trigger('refresh', 'item', ids, { embeddingsUpdate: true }) .catch(e => Zotero.logError(e)); }, INDEXED_NOTIFY_DELAY); } diff --git a/chrome/content/zotero/xpcom/notifier.js b/chrome/content/zotero/xpcom/notifier.js index 2d7af1969d..75776fe3ff 100644 --- a/chrome/content/zotero/xpcom/notifier.js +++ b/chrome/content/zotero/xpcom/notifier.js @@ -27,7 +27,7 @@ Zotero.Notifier = new function () { // Options that apply to an entire event, not a specific object - this.EVENT_LEVEL_OPTIONS = ['autoSyncDelay', 'skipAutoSync']; + this.EVENT_LEVEL_OPTIONS = ['autoSyncDelay', 'skipAutoSync', 'embeddingsUpdate']; var _observers = {}; var _types = [ diff --git a/test/tests/collectionViewItemTreeTest.js b/test/tests/collectionViewItemTreeTest.js index 50aa264118..2358e993c7 100644 --- a/test/tests/collectionViewItemTreeTest.js +++ b/test/tests/collectionViewItemTreeTest.js @@ -530,10 +530,31 @@ describe("CollectionViewItemTree", function () { // The indexer's coalesced notification after new/removed vectors best = itemB.id; - await Zotero.Notifier.trigger('refresh', 'item', [itemA.id, itemB.id]); + await Zotero.Notifier.trigger('refresh', 'item', [itemA.id, itemB.id], { embeddingsUpdate: true }); await itemsView._refreshPromise; assert.deepEqual(itemsView._rows.map(row => row.id), [itemB.id, itemA.id]); }); + + it("shouldn't rerank on a refresh that isn't an embeddings update", async function () { + let col = await createDataObject('collection'); + 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( + async (query, itemIDs) => new Map(itemIDs.map(id => [id, id == best ? 0.9 : 0.5])) + )); + + await select(win, col); + itemsView = zp.itemsView; + await itemsView.setFilter('search', 'some query'); + assert.deepEqual(itemsView._rows.map(row => row.id), [itemA.id, itemB.id]); + + // An unrelated refresh (e.g. a field change) leaves the ranking alone + best = itemB.id; + await Zotero.Notifier.trigger('refresh', 'item', [itemA.id, itemB.id]); + await itemsView._refreshPromise; + assert.deepEqual(itemsView._rows.map(row => row.id), [itemA.id, itemB.id]); + }); }); it("should expand parent item and attachment for an annotation match", async function () {