fix(db): dampen search rank by title length to stop repeat-term blobs outranking exact matches - #269
Conversation
… outranking exact matches FTS5's default bm25() rewards raw term frequency, so an authority whose title concatenates several child-entity names (repeating the query terms) could outrank an entity whose title matches the query once, cleanly (midt-bg#25)
…ly CSRF postcss 8.5.15 -> 8.5.18+ fixes GHSA-r28c-9q8g-f849 (path traversal via sourceMappingURL auto-load); valibot 1.4.0 -> 1.4.2+ fixes GHSA-5qjj-4xww-7phc (flatten() crash on inherited-property keys). Both are non-breaking patch-level overrides. react-router 7.18.0's GHSA-qwww-vcr4-c8h2 CSRF only affects the unstable RSC code paths (verified via repo-wide grep, zero hits) and has no fix in the 7.x line - suppressed via osv-scanner.toml with a 2026-10-01 review date rather than forcing a major 7.x -> 8.x bump across this PR.
|
Прегледах #269 на head Математиката е правилна: SQLite Тестът е дискриминиращ и реален: Две неща (не блокират):
|
|
@StanislavBG - прегледах PR-а и посоката е правилна: дефектът в #25 е реален и потискането по дължина го оправя. Не мога да го слея обаче, защото 1. Конфликтът (блокиращ)След като #271 влезе в main, 2. Две поправки по съществоИзразът изключва бързия път на FTS5. Тоест всеки съвпадащ ред се материализира и сортира преди Решението е двустепенно: вътрешна заявка взима топ 50 по оптимизирания път, външната преподрежда само тях. Резултатът за сценария от #25 е същият, а планът се връща на Тестът за дългите заглавия не може да падне. Готовата разлика (прилага се върху текущата ти глава): patchdiff --git a/packages/db/src/queries/search.ts b/packages/db/src/queries/search.ts
index ed603ed..994f002 100644
--- a/packages/db/src/queries/search.ts
+++ b/packages/db/src/queries/search.ts
@@ -104,15 +104,30 @@ interface HitRow {
// offset that within-document repetition, so we dampen `rank` by title length on top of it — the
// divisor grows by 1 per 20 chars, a soft enough curve that it reorders repeat-heavy blobs below
// clean short matches without sinking legitimate longer (but single-match) titles disproportionately.
-const RANK_EXPR = `rank / (1.0 + LENGTH(search_index.title) / 20.0)`;
+const RANK_EXPR = `r / (1.0 + LENGTH(title) / 20.0)`;
-export const SEARCH_HITS_SQL = `SELECT search_index.ref, search_index.title, search_index.ident,
- search_index.subtitle, search_index.amount,
+// Two stages, deliberately. `ORDER BY rank` is the ONLY form FTS5 optimizes: it drives the query with
+// its rank-ordering index and pushes the LIMIT down (EXPLAIN: `VIRTUAL TABLE INDEX 32:M6`). Ordering by
+// any expression OVER rank drops that (`INDEX 0:M6` + `USE TEMP B-TREE FOR ORDER BY`), so every row a
+// common term matches — tens of thousands for e.g. „община" — gets materialized and sorted before the
+// LIMIT. D1 bills rows read, so that is a real cost on the busiest query in the app.
+// So: take the top CANDIDATES by the optimized rank path, then re-rank only those. The inner slice is
+// wide enough that a repeat-heavy blob and the clean match it outranks are both inside it, and the
+// sort in the outer query is over at most CANDIDATES rows, not the whole match set.
+const CANDIDATES = 50;
+
+export const SEARCH_HITS_SQL = `SELECT h.ref, h.title, h.ident, h.subtitle, h.amount,
ct.kind AS entity_kind, ct.ownership_kind, ct.eik_valid
-FROM search_index
+FROM (
+ SELECT search_index.ref AS ref, search_index.title AS title, search_index.ident AS ident,
+ search_index.subtitle AS subtitle, search_index.amount AS amount,
+ search_index.kind AS kind, rank AS r
+ FROM search_index
+ WHERE search_index.kind = ? AND search_index MATCH ?
+ ORDER BY rank LIMIT ${CANDIDATES}
+) h
LEFT JOIN company_totals ct
- ON search_index.kind = 'company' AND ct.bidder_id = search_index.ref
-WHERE search_index.kind = ? AND search_index MATCH ?
+ ON h.kind = 'company' AND ct.bidder_id = h.ref
ORDER BY ${RANK_EXPR} LIMIT ?`;
export async function search(db: D1Database, rawQuery: string): Promise<SearchResults> {
diff --git a/packages/db/src/search-index-sql.test.ts b/packages/db/src/search-index-sql.test.ts
index 3e8fc80..178b35b 100644
--- a/packages/db/src/search-index-sql.test.ts
+++ b/packages/db/src/search-index-sql.test.ts
@@ -92,15 +92,33 @@ describe('search ranking SQL (real SQLite FTS5, SEARCH_HITS_SQL)', () => {
const titles = rankedTitles(dbPath, 'детска градина');
- // Both are single, clean matches for the query; a longer descriptive title matching once
- // should not be reordered to a wildly worse position than an equally clean shorter title —
- // it should still be a top hit, not pushed out by the length dampening meant for repeat-blobs.
- expect(titles).toEqual(
- expect.arrayContaining([
- 'Детска градина Дъга',
- 'Общинска детска градина за изкуство №5 към Столична община район Витоша',
- ]),
- );
+ // Both are single, clean matches. The dampening does put the shorter one first — that is its
+ // job — but the longer descriptive title must stay a top hit rather than being buried. Asserted
+ // as the EXACT order: `arrayContaining` would pass no matter how the two are ordered (both rows
+ // come back under LIMIT 10 regardless), so it could not detect the regression it guards against.
+ expect(titles).toEqual([
+ 'Детска градина Дъга',
+ 'Общинска детска градина за изкуство №5 към Столична община район Витоша',
+ ]);
+ });
+ });
+
+ // The ordering above can be satisfied by ordering the whole match set — which is exactly the
+ // regression to prevent. `ORDER BY rank` is the only form FTS5 optimizes (rank-ordering index +
+ // LIMIT pushdown); ordering by an expression over rank degrades to materializing and sorting every
+ // matching row. On „община"-class terms that is tens of thousands of rows per keystroke, and D1
+ // bills rows read. Lock the plan so a later simplification back to a single-level query is caught.
+ it('drives the match with FTS5 rank ordering, not a full sort of every match', () => {
+ withDb((dbPath) => {
+ insertAuthorityRows(dbPath, [['auth:1', 'Община Ботевград']]);
+ const sql = SEARCH_HITS_SQL.replace('?', "'authority'")
+ .replace('?', `'${searchMatchQuery('ботевград')}'`)
+ .replace('?', '6');
+ const plan = sqlite(dbPath, `EXPLAIN QUERY PLAN ${sql}`);
+
+ // idx 32 is FTS5's "ordering by rank" flag; idx 0 means it fell back to an unordered scan.
+ expect(plan).toMatch(/VIRTUAL TABLE INDEX 32/);
+ expect(plan).not.toMatch(/VIRTUAL TABLE INDEX 0/);
});
});
});Проверено локално върху текущия main: typecheck 7/7, lint чист, целият набор тестове зелен (39 файла в |
…act-vs-blob-25 # Conflicts: # osv-scanner.toml # pnpm-lock.yaml # pnpm-workspace.yaml
Land todorkolev's reviewed patch: two-stage query (inner ORDER BY rank LIMIT 50 using FTS5's rank-ordering index, outer re-rank over just those candidates) instead of ordering the whole match set by an expression over rank, which fell back to a full temp-B-tree sort. Also merge main to resolve the osv-scanner/pnpm conflict from midt-bg#271.
|
Ре-проверих новия връх Същина. Старият Тестовете са осезаемо по-добри. Старият Една бележка (не блокер, предсъществуваща). Печалбата в прочетени редове е частична: сестринската COUNT заявка ( Тредео (не дефект). Кодът е одобрим по същество; трябва rebase върху main (CONFLICTING). |
|
Този клон е в конфликт с |
Summary
bm25()rewards raw term frequency.rankby title length on top ofbm25's own normalization:rank / (1.0 + LENGTH(title) / 20.0), extracted into a reusableSEARCH_HITS_SQLconstant so the query and its test share one source instead of drifting.Test plan
packages/db/src/search-index-sql.test.ts) against a real SQLite FTS5search_index, using the real production SQL constant (not a hand-copied mirror) — reproduces the exact issue Качество на търсенето: възложител с дълъг списък имена изпреварва точните съвпадения #25 scenario (a municipality blob title vs. two exact-match kindergarten entities).ORDER BY rank(reverted locally, reran the test, failed as expected), confirmed green with the fix restored.@sigma/dbsuite: 285/285 tests pass, 35/35 files, no regressions.pnpm --filter @sigma/db typecheckandpnpm --filter web typecheckboth clean.🤖 Generated with a scheduler-executed PRD (692), independently re-verified (before/after test, full suite, typecheck) before opening this PR.