mirror of
https://github.com/zotero/zotero.git
synced 2026-09-11 22:51:15 +00:00
Normalize best-match queries
The query is trimmed and a single pair of wrapping quotes is stripped -- they carry no phrase semantics, since the whole query embeds as one string. A query that normalizes to nothing (e.g., just quotes) is treated as no search at all rather than being scored against noise.
This commit is contained in:
parent
eb0684d231
commit
c5d41229bb
5 changed files with 57 additions and 3 deletions
|
|
@ -718,7 +718,7 @@ Zotero.CollectionTreeRow.prototype.getBestMatchSource = function () {
|
|||
}
|
||||
// An active best-match quick search overrides a selected saved search's
|
||||
// own marker, so the user's typed query wins
|
||||
if (this.searchText
|
||||
if (this.searchText && Zotero.Embeddings.normalizeQuery(this.searchText)
|
||||
&& (this.searchMode || Zotero.Prefs.get('search.quicksearch-mode')) === 'bestMatch') {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -742,7 +742,7 @@ Zotero.CollectionTreeRow.prototype.isBestMatchSearch = function () {
|
|||
if (this.getBestMatchSource()) {
|
||||
return true;
|
||||
}
|
||||
return !!this.searchText
|
||||
return !!(this.searchText && Zotero.Embeddings.normalizeQuery(this.searchText))
|
||||
&& (this.searchMode || Zotero.Prefs.get('search.quicksearch-mode')) === 'bestMatch';
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -881,7 +881,8 @@ Zotero.Search.prototype.getBestMatchQuery = function () {
|
|||
else if (condition.condition == 'groupEnd') {
|
||||
depth--;
|
||||
}
|
||||
else if (depth == 0 && condition.condition == 'bestMatch' && condition.value) {
|
||||
else if (depth == 0 && condition.condition == 'bestMatch' && condition.value
|
||||
&& Zotero.Embeddings.normalizeQuery(condition.value)) {
|
||||
return {
|
||||
query: condition.value,
|
||||
topK: parseInt(condition.operator) || false
|
||||
|
|
|
|||
|
|
@ -685,6 +685,19 @@ Zotero.Embeddings = new function () {
|
|||
// search triggers (per-row membership cutoffs plus the merged ranking)
|
||||
let _queryCache = null;
|
||||
|
||||
/**
|
||||
* Normalize a best-match query: trim whitespace and strip a single pair
|
||||
* of wrapping quotes, which carry no phrase semantics here -- the whole
|
||||
* query embeds as one string. A query that normalizes to an empty string
|
||||
* is no query at all, and callers treat it as no active search.
|
||||
*
|
||||
* @param {String} text
|
||||
* @return {String}
|
||||
*/
|
||||
this.normalizeQuery = function (text) {
|
||||
return text.trim().replace(/^"(.*)"$/s, '$1').trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Embed a search query string, applying the active model's query prefix.
|
||||
*
|
||||
|
|
@ -692,6 +705,13 @@ Zotero.Embeddings = new function () {
|
|||
* @return {Promise<Float32Array>}
|
||||
*/
|
||||
this.embedQuery = function (text) {
|
||||
text = this.normalizeQuery(text);
|
||||
// Callers treat a query that normalizes to nothing as no search at
|
||||
// all, so it should never get this far -- embedding just the model's
|
||||
// query prefix would rank against noise
|
||||
if (!text) {
|
||||
throw new Error("Empty best-match query");
|
||||
}
|
||||
let modelVersion = this.getModelVersion();
|
||||
if (_queryCache && _queryCache.modelVersion === modelVersion
|
||||
&& _queryCache.text === text) {
|
||||
|
|
|
|||
|
|
@ -87,6 +87,31 @@ describe("Zotero.Embeddings", function () {
|
|||
}
|
||||
});
|
||||
|
||||
it("should strip a single pair of wrapping quotes", async function () {
|
||||
let embedStub = sinon.stub(Zotero.Embeddings, 'embed').resolves(new Float32Array([1]));
|
||||
let stubs = [
|
||||
sinon.stub(Zotero.Embeddings.Indexing, 'startIndexing').resolves(),
|
||||
sinon.stub(Zotero.Embeddings, 'pruneModels').resolves(),
|
||||
embedStub
|
||||
];
|
||||
Zotero.Prefs.set('embeddings.model', 'bge-small-en-v1.5');
|
||||
try {
|
||||
await Zotero.Embeddings.Indexing.waitForPendingModelSwitch();
|
||||
// Whitespace around the quotes doesn't defeat the stripping
|
||||
await Zotero.Embeddings.embedQuery(' "wrapped query" ');
|
||||
assert.include(embedStub.firstCall.args[0], 'wrapped query');
|
||||
assert.notInclude(embedStub.firstCall.args[0], '"');
|
||||
// A query that normalizes to nothing is a caller bug
|
||||
assert.throws(() => Zotero.Embeddings.embedQuery('""'));
|
||||
}
|
||||
finally {
|
||||
Zotero.Prefs.set('embeddings.model', '');
|
||||
await Zotero.Embeddings.Indexing.waitForPendingModelSwitch();
|
||||
Zotero.Prefs.clear('embeddings.indexingPaused');
|
||||
stubs.forEach(stub => stub.restore());
|
||||
}
|
||||
});
|
||||
|
||||
it("should share one in-flight embed across concurrent calls", async function () {
|
||||
let deferred = Zotero.Promise.defer();
|
||||
let stubs = [
|
||||
|
|
|
|||
|
|
@ -234,6 +234,14 @@ describe("Zotero.Search", function () {
|
|||
assert.deepEqual(s.getBestMatchQuery(), { query: 'some query', topK: false });
|
||||
assert.sameMembers(await s.search(), [itemA.id, itemB.id]);
|
||||
|
||||
// A query that normalizes to nothing (e.g., just quotes) is no
|
||||
// query at all
|
||||
let sEmpty = new Zotero.Search();
|
||||
sEmpty.libraryID = itemA.libraryID;
|
||||
sEmpty.addCondition('resultLevel', 'item');
|
||||
sEmpty.addCondition('bestMatch', 'contains', '""');
|
||||
assert.isFalse(sEmpty.getBestMatchQuery());
|
||||
|
||||
// With a cutoff, membership is the K most similar
|
||||
let stub = sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake(
|
||||
async (query, itemIDs) => new Map(itemIDs.map(id => [id, id == itemB.id ? 0.9 : 0.5]))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue