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.
This commit is contained in:
Dan Stillman 2026-07-24 13:35:40 -04:00
parent 24524e1279
commit 2112da0766
4 changed files with 31 additions and 10 deletions

View file

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

View file

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

View file

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

View file

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