From 2bca26ddd61a8dc53ec0b5b41e783eebe1c15fa0 Mon Sep 17 00:00:00 2001 From: Tao Zeyu Date: Sat, 4 Jul 2026 10:35:43 +0800 Subject: [PATCH] fix: preserve FTS ranking order --- src/archive/query/search-cache.ts | 2 +- src/archive/search-index/search-index.ts | 5 +- test/archive/query/archive-view.test.ts | 111 +++++++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/archive/query/search-cache.ts b/src/archive/query/search-cache.ts index b0d64d86..cfba3f96 100644 --- a/src/archive/query/search-cache.ts +++ b/src/archive/query/search-cache.ts @@ -216,7 +216,7 @@ CREATE INDEX IF NOT EXISTS idx_search_sessions_prune ON search_sessions(accessed_at DESC, created_at DESC, session_id); `; -const SEARCH_RANKING_VERSION = 5; +const SEARCH_RANKING_VERSION = 6; const SEARCH_SESSION_MAX_COUNT = 500; const SEARCH_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; const SEARCH_TOP_SCORE_COUNT = 10; diff --git a/src/archive/search-index/search-index.ts b/src/archive/search-index/search-index.ts index ef1a5e1f..1c90013f 100644 --- a/src/archive/search-index/search-index.ts +++ b/src/archive/search-index/search-index.ts @@ -110,6 +110,7 @@ export interface SearchIndexQueryResult { const SEARCH_INDEX_VERSION = "3"; export const SEARCH_INDEX_FTS_HIT_LIMIT = 32_000; +const FTS5_RANK_SCORE_SCALE = 1_000_000; const TIER_WEIGHTS = [1, 0.45, 0.08] as const; export interface ArchiveIndexSettings { @@ -748,7 +749,9 @@ function createTextKindFilter( } function rankToScore(rank: number): number { - return 1 / (1 + Math.max(0, Math.abs(rank))); + const relevance = Math.max(0, -rank) * FTS5_RANK_SCORE_SCALE; + + return relevance / (1 + relevance); } function createObjectHitKey(hit: SearchIndexObjectHit): string { diff --git a/test/archive/query/archive-view.test.ts b/test/archive/query/archive-view.test.ts index 9d565a5d..7b24862b 100644 --- a/test/archive/query/archive-view.test.ts +++ b/test/archive/query/archive-view.test.ts @@ -196,6 +196,117 @@ describe("archive/query/archive-view", () => { }); }); + it("orders text search hits by FTS relevance within the text bucket", async () => { + await withTempDir("spinedigest-archive-view-", async (path) => { + const document = await DirectoryDocument.open(`${path}/document`); + + try { + await document.openSession(async (openedDocument) => { + await openedDocument.createSerial(); + const draft = await openedDocument + .getSerialFragments(1) + .createDraft(); + + draft.addSentence("alpha appears in a weak candidate.", 6); + draft.addSentence("alpha beta alpha beta is the strongest match.", 8); + await draft.commit(); + await openedDocument.writeBookMeta({ + authors: [], + description: null, + identifier: null, + language: "en", + publishedAt: null, + publisher: null, + sourceFormat: "markdown", + title: "Search Ranking Fixture", + version: 1, + }); + await openedDocument.writeToc({ + items: [ + { + children: [], + serialId: 1, + title: "Ranking", + }, + ], + version: 1, + }); + }); + await rebuildArchiveSearchIndex(document); + + const result = await findArchiveObjects(document, "alpha beta", { + limit: 10, + types: ["source"], + }); + + expect(result.items.map((item) => item.id)).toStrictEqual([ + "wikg://chapter/1/source#1", + "wikg://chapter/1/source#0", + ]); + expect(result.items[0]?.score).toBeGreaterThan( + result.items[1]?.score ?? 0, + ); + } finally { + await document.release(); + } + }); + }); + + it("orders chapter search hits by FTS relevance within the object property bucket", async () => { + await withTempDir("spinedigest-archive-view-", async (path) => { + const document = await DirectoryDocument.open(`${path}/document`); + + try { + await document.openSession(async (openedDocument) => { + await openedDocument.createSerial(); + await openedDocument.createSerial(); + await openedDocument.writeBookMeta({ + authors: [], + description: null, + identifier: null, + language: "en", + publishedAt: null, + publisher: null, + sourceFormat: "markdown", + title: "Chapter Ranking Fixture", + version: 1, + }); + await openedDocument.writeToc({ + items: [ + { + children: [], + serialId: 1, + title: "alpha appears in a weak chapter", + }, + { + children: [], + serialId: 2, + title: "alpha beta alpha beta strongest chapter", + }, + ], + version: 1, + }); + }); + await rebuildArchiveSearchIndex(document); + + const result = await findArchiveObjects(document, "alpha beta", { + limit: 10, + types: ["chapter"], + }); + + expect(result.items.map((item) => item.id)).toStrictEqual([ + "chapter:2", + "chapter:1", + ]); + expect(result.items[0]?.score).toBeGreaterThan( + result.items[1]?.score ?? 0, + ); + } finally { + await document.release(); + } + }); + }); + it("limits FTS candidates before search cache hydration", async () => { await withTempDir("spinedigest-archive-view-", async (path) => { const document = await DirectoryDocument.open(`${path}/document`);