mirror of
https://github.com/captbaritone/webamp.git
synced 2026-11-07 10:25:59 -10:00
Rank skins whose file names match a search first
FTS5's rank (BM25) scores a match lower the longer the whole row is, so
a long readme buries a skin even when the search is for its own file
name: "Atlas" put Skinner_Atlas.wsz, with a 49 KB readme, 136th of 165.
Weighting the file_names column doesn't help, since the length penalty
is for the whole row. So order skins with every word of the search in
their file names first, then by rank. It's now 7th, and the broadest
search ("winamp") takes 141ms instead of 44ms.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
66095c0b9c
commit
72858ec18e
4 files changed
+61
-4
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { ftsMatchExpression } from "../ftsQuery";
|
||||
import { ftsColumnExpression, ftsMatchExpression } from "../ftsQuery";
|
||||
|
||||
describe("ftsMatchExpression", () => {
|
||||
it("quotes each token and prefix matches the last one", () => {
|
||||
@@ -38,3 +38,11 @@ describe("ftsMatchExpression", () => {
|
||||
expect(ftsMatchExpression("+++")).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ftsColumnExpression", () => {
|
||||
it("limits a match expression to one column", () => {
|
||||
expect(ftsColumnExpression("file_names", '"green" "day"*')).toBe(
|
||||
'file_names : ("green" "day"*)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -537,6 +537,33 @@ test("An Upload Flow", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("search_classic_skins ranks file name matches above readme matches", async () => {
|
||||
// A long readme sinks a skin's rank in the search index, even when the
|
||||
// search is for the skin's own file name.
|
||||
await knex("files").insert({
|
||||
skin_md5: "a_fake_md5",
|
||||
file_path: "/a/fake/Skinner_Atlas.wsz",
|
||||
});
|
||||
await knex("skins")
|
||||
.where({ md5: "a_fake_md5" })
|
||||
.update({ readme_text: `Atlas. ${"Lots of other words. ".repeat(500)}` });
|
||||
await knex("skins")
|
||||
.where({ md5: "an_approved_md5" })
|
||||
.update({ readme_text: "Inspired by Atlas" });
|
||||
|
||||
const { data } = await graphQLRequest(gql`
|
||||
query {
|
||||
search_classic_skins(query: "atlas") {
|
||||
md5
|
||||
}
|
||||
}
|
||||
`);
|
||||
expect(data.search_classic_skins).toEqual([
|
||||
{ md5: "a_fake_md5" },
|
||||
{ md5: "an_approved_md5" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("the search index follows skins and their files", async () => {
|
||||
const search = async (query: string) => {
|
||||
const { data } = await graphQLRequest(
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ISkin } from "./CommonSkinResolver";
|
||||
import ModernSkinResolver from "./ModernSkinResolver";
|
||||
import * as Skins from "../../../data/skins";
|
||||
import { knex } from "../../../db";
|
||||
import { ftsMatchExpression } from "../../../ftsQuery";
|
||||
import { ftsColumnExpression, ftsMatchExpression } from "../../../ftsQuery";
|
||||
|
||||
export default class SkinResolver {
|
||||
constructor() {
|
||||
@@ -28,6 +28,20 @@ export default class SkinResolver {
|
||||
}
|
||||
}
|
||||
|
||||
// How to order a skin_search MATCH's results. FTS5's rank (BM25) scores a
|
||||
// match lower the longer the whole row is, readme included, so a long readme
|
||||
// can bury a skin whose file name is just what was searched for: "Atlas" put
|
||||
// Skinner_Atlas.wsz, with a 49 KB readme, 136th of 165. So skins with every
|
||||
// word of the search in their file names come first, then by rank.
|
||||
function searchOrder(match: string): [string, string[]] {
|
||||
return [
|
||||
`skin_search.skin_md5 IN (
|
||||
SELECT skin_md5 FROM skin_search WHERE skin_search MATCH ?
|
||||
) DESC, rank`,
|
||||
[ftsColumnExpression("file_names", match)],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a skin by its MD5 hash
|
||||
* @gqlQueryField
|
||||
@@ -68,7 +82,7 @@ export async function search_skins(
|
||||
.select("skin_md5")
|
||||
.leftJoin("skins", "skin_search.skin_md5", "skins.md5")
|
||||
.where("skins.skin_type", "in", [1, 2])
|
||||
.orderByRaw("rank")
|
||||
.orderByRaw(...searchOrder(match))
|
||||
.limit(first)
|
||||
.offset(offset)
|
||||
.whereRaw("skin_search MATCH ?", match);
|
||||
@@ -123,7 +137,7 @@ export async function search_classic_skins(
|
||||
.select("skin_md5")
|
||||
.leftJoin("skins", "skin_search.skin_md5", "skins.md5")
|
||||
.where("skins.skin_type", "=", 1)
|
||||
.orderByRaw("rank")
|
||||
.orderByRaw(...searchOrder(match))
|
||||
.limit(first)
|
||||
.offset(offset)
|
||||
.whereRaw("skin_search MATCH ?", match);
|
||||
|
||||
@@ -35,3 +35,11 @@ export function ftsMatchExpression(userInput: string): string | null {
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit a MATCH expression from ftsMatchExpression to one column of the index,
|
||||
* e.g. to find the rows where every word of the search is in their file names.
|
||||
*/
|
||||
export function ftsColumnExpression(column: string, match: string): string {
|
||||
return `${column} : (${match})`;
|
||||
}
|
||||
Reference in new issue
Block a user