Improve search logic (#6637)

# Description of Changes
Search has got significantly worse since #6581, where I added all the
missing tags for tools that should have been there for months. Turns out
that the fuzzy matching search logic has always been way too permissive
to match words with Levenshtein distances way too far away from the
target word, so long searches include way too much stuff. The new tags
just exposed that underlying logic issue. This PR makes the Levenshtein
logic much stricter, so it is still tolerant to minor typos in tool
names, but doesn't match completely inappropriate strings.
This commit is contained in:
James Brunton
2026-06-12 10:34:11 +01:00
committed by GitHub
parent ea102cdb93
commit d363a1e957
4 changed files with 149 additions and 14 deletions
@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import {
scoreMatch,
minScoreForQuery,
isFuzzyMatch,
} from "@app/utils/fuzzySearch";
describe("scoreMatch", () => {
it("scores substring matches highest", () => {
expect(scoreMatch("rota", "Rotate")).toBeGreaterThanOrEqual(90);
expect(scoreMatch("priv", "private")).toBeGreaterThanOrEqual(
minScoreForQuery("priv"),
);
});
it("tolerates small typos", () => {
expect(isFuzzyMatch("rotaet", "rotate")).toBe(true);
expect(isFuzzyMatch("comprss", "compress")).toBe(true);
// Typo inside one token of a multi-word target
expect(isFuzzyMatch("watermrk", "Add Watermark")).toBe(true);
});
it("rejects unrelated words that share half their letters", () => {
// Regression: the old fallback accepted 50% relative Levenshtein
// similarity, so longer queries matched more junk (rotate vs update is
// edit distance 3 of 6)
expect(isFuzzyMatch("rotate", "update")).toBe(false);
expect(isFuzzyMatch("rotate", "create")).toBe(false);
expect(isFuzzyMatch("rotate", "private")).toBe(false);
expect(isFuzzyMatch("rotate", "isolated")).toBe(false);
expect(isFuzzyMatch("rotate", "automate")).toBe(false);
expect(isFuzzyMatch("rotate", "annotate")).toBe(false);
expect(isFuzzyMatch("rotat", "protect")).toBe(false);
expect(isFuzzyMatch("rotat", "redact")).toBe(false);
expect(isFuzzyMatch("rotat", "contrast")).toBe(false);
expect(isFuzzyMatch("rotat", "annotate")).toBe(false);
});
});
describe("minScoreForQuery", () => {
it("does not loosen the threshold for long queries", () => {
expect(minScoreForQuery("rot")).toBe(40);
expect(minScoreForQuery("rotate")).toBe(30);
expect(minScoreForQuery("orientation")).toBe(30);
});
});
+17 -10
View File
@@ -63,20 +63,27 @@ export function scoreMatch(queryRaw: string, targetRaw: string): number {
}
}
const distance = levenshtein(
query,
target.length > 64 ? target.slice(0, 64) : target,
);
const maxLen = Math.max(query.length, target.length, 1);
const similarity = 1 - distance / maxLen; // 0..1
return Math.floor(similarity * 60); // scale below substring scores
// Levenshtein fallback is for typos only: allow a small absolute number of
// edits against the whole target or any single token. Relative similarity is
// not enough here; half the letters of an unrelated word can match (e.g.
// "rotate" vs "update" is edit distance 3 of 6).
const maxEdits = query.length <= 4 ? 1 : 2;
let best = 0;
for (const candidate of [target, ...tokens]) {
if (Math.abs(query.length - candidate.length) > maxEdits) continue;
const distance = levenshtein(query, candidate);
if (distance > maxEdits) continue;
const maxLen = Math.max(query.length, candidate.length, 1);
const similarity = 1 - distance / maxLen; // 0..1
const score = Math.floor(similarity * 60); // scale below substring scores
if (score > best) best = score;
}
return best;
}
export function minScoreForQuery(query: string): number {
const len = normalizeText(query).length;
if (len <= 3) return 40;
if (len <= 6) return 30;
return 25;
return len <= 3 ? 40 : 30;
}
// Decide if a target matches a query based on a threshold
@@ -0,0 +1,82 @@
import { describe, it, expect } from "vitest";
import { filterToolRegistryByQuery } from "@app/utils/toolSearch";
import {
ToolCategoryId,
SubcategoryId,
ToolRegistry,
ToolRegistryEntry,
} from "@app/data/toolsTaxonomy";
function makeEntry(name: string, tags: string): ToolRegistryEntry {
return {
icon: null,
name,
component: null,
description: "",
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
automationSettings: null,
synonyms: tags.split(","),
};
}
// Real names and tags from the en-GB translations; these tools previously
// polluted results for partial queries like "rotat" and "rotate"
const registry: Partial<ToolRegistry> = {
rotate: makeEntry(
"Rotate",
"turn,flip,orient,rotate,orientation,landscape,portrait,90 degrees,180 degrees,clockwise,anticlockwise,counter-clockwise,fix orientation",
),
addPassword: makeEntry(
"Add Password",
"encrypt,password,lock,secure,protect,security,encryption,safeguard,confidential,private,restrict access",
),
changeMetadata: makeEntry(
"Change Metadata",
"edit,modify,update,metadata,properties,document properties,author,title,subject,keywords,creator,producer,info,document info,file properties",
),
scannerEffect: makeEntry(
"Scanner Effect",
"scan,simulate,create,fake scan,look scanned,scanner effect,make look scanned,photocopy effect,simulate scanner,realistic scan",
),
adjustContrast: makeEntry(
"Adjust Colours/Contrast",
"contrast,brightness,saturation,adjust colors,color correction,enhance,lighten,darken,improve quality,color balance,hue,vibrance",
),
annotate: makeEntry(
"Annotate",
"annotate,highlight,draw,markup,comment,notes,review,redline,feedback,markup tools,sticky notes,shapes,arrows,text box,freehand",
),
redact: makeEntry(
"Redact",
"censor,blackout,hide,redact,redaction,black out,block out,remove sensitive,hide text,privacy,confidential,GDPR,PII,sensitive data,permanently remove,cover up,legal redaction",
),
compare: makeEntry(
"Compare",
"difference,compare,diff,compare PDFs,compare documents,find differences,show differences,changes,what changed,track changes,revisions,version compare,side by side,contrast,delta",
),
};
function idsFor(query: string): string[] {
return filterToolRegistryByQuery(registry, query).map(({ item: [id] }) => id);
}
describe("filterToolRegistryByQuery", () => {
it("returns everything for an empty query", () => {
expect(idsFor("")).toHaveLength(Object.keys(registry).length);
});
it("matches tools by tag substring", () => {
expect(idsFor("protect")).toEqual(["addPassword"]);
expect(idsFor("orientation")).toEqual(["rotate"]);
});
it("only returns Rotate for every prefix of 'rotate'", () => {
// Regression: "rotat" used to also pull in Redact, Annotate, Compare and
// Adjust Colours/Contrast, and "rotate" additionally Scanner Effect,
// Change Metadata, Add Password and Air-gapped Setup
expect(idsFor("rota")).toEqual(["rotate"]);
expect(idsFor("rotat")).toEqual(["rotate"]);
expect(idsFor("rotate")).toEqual(["rotate"]);
});
});