diff --git a/.changeset/omnibar-tier-dominant-ranking.md b/.changeset/omnibar-tier-dominant-ranking.md new file mode 100644 index 00000000..caaf0dc8 --- /dev/null +++ b/.changeset/omnibar-tier-dominant-ranking.md @@ -0,0 +1,5 @@ +--- +"@inkeep/open-knowledge": patch +--- + +Rank the Cmd+K omnibar name-first. An exact filename match now leads the results even when many files share that basename and have stronger body-text scores — the file you typed is no longer buried below same-named siblings or pushed past the fetch limit. The omnibar still searches content, but a strong content match only reorders within a name-match tier rather than outranking the name itself; the deliberate "by meaning" search keeps content-relevance ranking. A query that matches many folders or name-only files (`evidence`, `index`) no longer fills the whole list with one kind — folders and files are bounded so content pages fill the rest. diff --git a/packages/app/src/components/CommandPalette.dom.test.tsx b/packages/app/src/components/CommandPalette.dom.test.tsx index 446b9bac..012e8d72 100644 --- a/packages/app/src/components/CommandPalette.dom.test.tsx +++ b/packages/app/src/components/CommandPalette.dom.test.tsx @@ -372,3 +372,38 @@ describe('CommandPalette DOM behavior', () => { expect(commandDialogProps.at(-1)?.placement).toBeUndefined(); }); }); + +describe('NavigationItem path subtitle', () => { + beforeEach(() => { + cleanup(); + }); + + test('a file result row renders its path so same-named siblings are distinguishable', async () => { + const { NavigationItem } = await import('./CommandPalette'); + const fileA = { + kind: 'file' as const, + path: 'reports/q3/data.csv', + name: 'data.csv', + title: 'data.csv', + score: 1, + }; + const fileB = { + kind: 'file' as const, + path: 'exports/legacy/data.csv', + name: 'data.csv', + title: 'data.csv', + score: 1, + }; + render( + <> + {}} /> + {}} /> + , + ); + + const rowA = screen.getByTestId('command-palette-nav-file-reports/q3/data.csv'); + const rowB = screen.getByTestId('command-palette-nav-file-exports/legacy/data.csv'); + expect(rowA.textContent).toContain('reports/q3/data.csv'); + expect(rowB.textContent).toContain('exports/legacy/data.csv'); + }); +}); diff --git a/packages/app/src/components/CommandPalette.tsx b/packages/app/src/components/CommandPalette.tsx index a015ee91..e9a3a14d 100644 --- a/packages/app/src/components/CommandPalette.tsx +++ b/packages/app/src/components/CommandPalette.tsx @@ -114,7 +114,7 @@ function resolveCreateInitialDir( return defaultInitialDir(activeDocName); } -function NavigationItem({ +export function NavigationItem({ entry, query = '', onSelect, diff --git a/packages/app/src/components/command-palette-search.test.ts b/packages/app/src/components/command-palette-search.test.ts index 80468ab7..6bf9ace2 100644 --- a/packages/app/src/components/command-palette-search.test.ts +++ b/packages/app/src/components/command-palette-search.test.ts @@ -267,8 +267,9 @@ describe('fetchWorkspaceSearchEntries', () => { expect(requestBody).toEqual({ query: 'homepage', intent: 'full_text', + ranking: 'navigation', scopes: ['page', 'folder', 'content', 'file'], - limit: 30, + limit: 50, source: 'omnibar', }); expect(entries).toEqual([ @@ -297,8 +298,9 @@ describe('fetchWorkspaceSearchEntries', () => { expect(requestBody).toEqual({ query: 'auth retries', intent: 'full_text', + ranking: 'relevance', scopes: ['page', 'folder', 'content', 'file'], - limit: 30, + limit: 50, source: 'omnibar', semantic: true, }); diff --git a/packages/app/src/components/command-palette-search.ts b/packages/app/src/components/command-palette-search.ts index ba809a16..9965aa86 100644 --- a/packages/app/src/components/command-palette-search.ts +++ b/packages/app/src/components/command-palette-search.ts @@ -37,7 +37,7 @@ interface WorkspaceEntrySearchCorpus { export const EMPTY_QUERY_NAV_LIMIT = 20; const MATCH_QUERY_NAV_LIMIT = 50; -const API_SEARCH_LIMIT = 30; +const API_SEARCH_LIMIT = 50; export const SEMANTIC_RESULT_LIMIT = 30; let cachedEntriesFingerprint = ''; @@ -249,6 +249,7 @@ export async function fetchWorkspaceSearchEntries( body: JSON.stringify({ query: normalizedQuery, intent: 'full_text', + ranking: options.semantic ? 'relevance' : 'navigation', scopes: ['page', 'folder', 'content', 'file'], limit: options.limit ?? API_SEARCH_LIMIT, source: 'omnibar', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 111507ce..f7a956ac 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -871,6 +871,7 @@ export { type WorkspaceSearchIntent, type WorkspaceSearchKind, type WorkspaceSearchOptions, + type WorkspaceSearchRanking, type WorkspaceSearchResult, type WorkspaceSearchScope, type WorkspaceSemanticInput, diff --git a/packages/core/src/schemas/api/tags-search.ts b/packages/core/src/schemas/api/tags-search.ts index 950d7ab9..e8eb088d 100644 --- a/packages/core/src/schemas/api/tags-search.ts +++ b/packages/core/src/schemas/api/tags-search.ts @@ -158,6 +158,7 @@ export const SearchRequestSchema = z .object({ query: z.string().optional(), intent: z.enum(['autocomplete', 'full_text', 'omnibar']).optional(), + ranking: z.enum(['navigation', 'relevance']).optional(), scopes: z.array(z.enum(['page', 'folder', 'content', 'file'])).optional(), scope: z.string().optional(), limit: z.number().int().nonnegative().optional(), diff --git a/packages/core/src/search/workspace-search.baseline.fixture.json b/packages/core/src/search/workspace-search.baseline.fixture.json index a3f38e09..6b5a5895 100644 --- a/packages/core/src/search/workspace-search.baseline.fixture.json +++ b/packages/core/src/search/workspace-search.baseline.fixture.json @@ -206,7 +206,7 @@ "expected": [ { "id": "page:auth/login", - "score": 1707.1890049060949, + "score": 700001.25, "signals": { "lexical": 700, "fullText": 49.734450245304735, @@ -215,7 +215,7 @@ }, { "id": "page:misc/login-history", - "score": 1479.3204629742795, + "score": 600001.47759861, "signals": { "lexical": 600, "fullText": 42.40352314871397, @@ -232,7 +232,7 @@ "expected": [ { "id": "page:auth/login", - "score": 1890.912389055217, + "score": 700001.25, "signals": { "lexical": 700, "fullText": 58.92061945276085, @@ -241,7 +241,7 @@ }, { "id": "page:misc/login-history", - "score": 1626.389211640448, + "score": 600001.4694744988, "signals": { "lexical": 600, "fullText": 49.756960582022394, @@ -302,34 +302,34 @@ "expected": [ { "id": "folder:auth", - "score": 1573.1702123532718, + "score": 700001.6623001031, "signals": { "lexical": 700, "fullText": 41.783510617663595, "recency": 37.5 } }, - { - "id": "page:guides/authentication-overview", - "score": 1566.0036368361461, - "signals": { - "lexical": 600, - "fullText": 45.80018184180731, - "recency": 50 - } - }, { "id": "page:auth", - "score": 1554.4202123532718, + "score": 700001.2873001031, "signals": { "lexical": 700, "fullText": 41.783510617663595, "recency": 18.75 } }, + { + "id": "page:guides/authentication-overview", + "score": 600002, + "signals": { + "lexical": 600, + "fullText": 45.80018184180731, + "recency": 50 + } + }, { "id": "page:auth/session-token-refresh", - "score": 713.485200031778, + "score": 550000.6511841159, "signals": { "lexical": 550, "fullText": 6.9242600015889, @@ -338,7 +338,7 @@ }, { "id": "page:auth/login", - "score": 700.985200031778, + "score": 550000.4011841159, "signals": { "lexical": 550, "fullText": 6.9242600015889, @@ -495,7 +495,7 @@ "expected": [ { "id": "folder:recipes", - "score": 1808.6874410044754, + "score": 700001, "signals": { "lexical": 700, "fullText": 55.43437205022377, diff --git a/packages/core/src/search/workspace-search.test.ts b/packages/core/src/search/workspace-search.test.ts index 0adf5068..8ad3b9f5 100644 --- a/packages/core/src/search/workspace-search.test.ts +++ b/packages/core/src/search/workspace-search.test.ts @@ -35,8 +35,8 @@ describe('searchWorkspaceDocuments', () => { const results = searchWorkspaceDocuments(documents, 'arch', { intent: 'omnibar' }); expect(results.map((result) => result.document.path)).toEqual([ - 'architecture', 'architecture/overview', + 'architecture', ]); }); @@ -197,19 +197,19 @@ describe('canonical-kind ranking — markdown outranks a same-stem file (D5)', ( expect(ranked[0]?.document.path).toBe('foo'); }); - test('a markdown page outranks a same-bracket file even when the file is newer (kind demotion dominates recency)', () => { + test('a markdown page outranks a same-stem file at equal recency (kind is the within-tier tiebreaker)', () => { const page = createWorkspaceSearchDocument({ kind: 'page', path: 'config', title: 'config', modifiedTs: 10, }); - const newerFile = createWorkspaceSearchDocument({ + const file = createWorkspaceSearchDocument({ kind: 'file', path: 'sub/config', - modifiedTs: 100, + modifiedTs: 10, }); - const ranked = searchWorkspaceDocuments([page, newerFile], 'config', { intent: 'omnibar' }); + const ranked = searchWorkspaceDocuments([page, file], 'config', { intent: 'omnibar' }); const pageRank = ranked.findIndex((r) => r.document.path === 'config'); const fileRank = ranked.findIndex((r) => r.document.path === 'sub/config'); expect(pageRank).toBeGreaterThanOrEqual(0); diff --git a/packages/core/src/search/workspace-search.tier-ranking.test.ts b/packages/core/src/search/workspace-search.tier-ranking.test.ts new file mode 100644 index 00000000..0c36ae70 --- /dev/null +++ b/packages/core/src/search/workspace-search.tier-ranking.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, test } from 'bun:test'; +import { + createWorkspaceSearchDocument, + DEFAULT_FOLDER_RESULT_CAP, + searchWorkspaceDocuments, +} from './workspace-search.ts'; + +describe('tier-dominant ranking — identity beats body relevance', () => { + const exact = createWorkspaceSearchDocument({ + kind: 'page', + path: 'cloud-collaboration/STORY', + title: 'User Stories', + modifiedTs: 5, + }); + const bodyHeavyFolder = createWorkspaceSearchDocument({ + kind: 'folder', + path: 'story/story-archive/storyboard', + modifiedTs: 50, + }); + const bodyHeavyPage = createWorkspaceSearchDocument({ + kind: 'page', + path: 'storybook/storybook-notes', + title: 'Storybook Storybook Story patterns', + modifiedTs: 50, + }); + const corpus = [exact, bodyHeavyFolder, bodyHeavyPage]; + + test('an exact-name match leads partials that have a strictly higher body score', () => { + const results = searchWorkspaceDocuments(corpus, 'story', { intent: 'omnibar' }); + expect(results[0]?.document.path).toBe('cloud-collaboration/STORY'); + + const exactHit = results.find((r) => r.document.path === 'cloud-collaboration/STORY'); + const partialHits = results.filter((r) => r.document.path !== 'cloud-collaboration/STORY'); + expect(partialHits.length).toBeGreaterThan(0); + for (const partial of partialHits) { + expect(partial.signals.fullText).toBeGreaterThan(exactHit?.signals.fullText ?? 0); + } + }); + + test('every exact-name page outranks every partial-name match regardless of body', () => { + const stories = ['cloud-collaboration', 'agent-presence', 'realtime-frontmatter'].map((slug) => + createWorkspaceSearchDocument({ + kind: 'page', + path: `stories/${slug}/STORY`, + title: `${slug} user stories`, + modifiedTs: 1, + }), + ); + const partials = [1, 2, 3].map((n) => + createWorkspaceSearchDocument({ + kind: 'page', + path: `reports/storybook-${n}/storybook-${n}`, + title: `Storybook story story story ${n}`, + modifiedTs: 90, + }), + ); + const results = searchWorkspaceDocuments([...partials, ...stories], 'story', { + intent: 'omnibar', + }); + const lastExact = Math.max( + ...results + .map((r, i) => ({ i, name: r.document.path.split('/').pop() })) + .filter((r) => r.name === 'STORY') + .map((r) => r.i), + ); + const firstPartial = results.findIndex((r) => r.document.path.includes('storybook')); + expect(lastExact).toBeLessThan(firstPartial); + }); + + test('a buried exact-name match surfaces into the top of its tier via recency', () => { + const target = createWorkspaceSearchDocument({ + kind: 'page', + path: 'cloud-collaboration/STORY', + title: 'Collaboration notes', + modifiedTs: 100, + }); + const siblings = Array.from({ length: 12 }, (_, n) => + createWorkspaceSearchDocument({ + kind: 'page', + path: `stories/topic-${n}/STORY`, + title: `Story story story ${n}`, + modifiedTs: n, + }), + ); + const results = searchWorkspaceDocuments([...siblings, target], 'story', { intent: 'omnibar' }); + expect(results[0]?.document.path).toBe('cloud-collaboration/STORY'); + }); +}); + +describe('intent-aware scoring — full_text stays body-weighted', () => { + const exact = createWorkspaceSearchDocument({ + kind: 'page', + path: 'a/STORY', + title: 'Quiet notes', + content: 'unrelated prose', + modifiedTs: 5, + }); + const bodyHeavy = createWorkspaceSearchDocument({ + kind: 'page', + path: 'b/storybook-deep-dive', + title: 'Storybook story story story', + content: 'story story story story story story', + modifiedTs: 5, + }); + const corpus = [exact, bodyHeavy]; + + test('omnibar puts the exact name first; full_text puts the strong body match first', () => { + const omnibar = searchWorkspaceDocuments(corpus, 'story', { intent: 'omnibar' }); + expect(omnibar[0]?.document.path).toBe('a/STORY'); + + const fullText = searchWorkspaceDocuments(corpus, 'story', { intent: 'full_text' }); + expect(fullText[0]?.document.path).toBe('b/storybook-deep-dive'); + }); +}); + +describe('ranking decoupled from intent — the omnibar config', () => { + const exact = createWorkspaceSearchDocument({ + kind: 'page', + path: 'cloud-collaboration/STORY', + title: 'Quiet notes', + content: 'unrelated prose', + modifiedTs: 5, + }); + const bodyHeavy = createWorkspaceSearchDocument({ + kind: 'page', + path: 'storybook/deep-dive', + title: 'Storybook story story story', + content: 'story story story story story story', + modifiedTs: 5, + }); + + test('navigation ranking over a full_text candidate set puts the exact name first', () => { + const nav = searchWorkspaceDocuments([exact, bodyHeavy], 'story', { + intent: 'full_text', + ranking: 'navigation', + }); + expect(nav[0]?.document.path).toBe('cloud-collaboration/STORY'); + + const relevance = searchWorkspaceDocuments([exact, bodyHeavy], 'story', { + intent: 'full_text', + }); + expect(relevance[0]?.document.path).toBe('storybook/deep-dive'); + }); + + test('navigation ranking applies the per-kind cap; relevance does not', () => { + const folders = ['a', 'b', 'c', 'd', 'e'].map((p) => + createWorkspaceSearchDocument({ kind: 'folder', path: `${p}/reports`, modifiedTs: 0 }), + ); + const scopes = ['page', 'folder', 'file'] as const; + + const capped = searchWorkspaceDocuments(folders, 'reports', { + intent: 'full_text', + ranking: 'navigation', + scopes, + }); + expect(capped.filter((r) => r.document.kind === 'folder').length).toBe( + DEFAULT_FOLDER_RESULT_CAP, + ); + + const uncapped = searchWorkspaceDocuments(folders, 'reports', { + intent: 'full_text', + ranking: 'relevance', + scopes, + }); + expect(uncapped.filter((r) => r.document.kind === 'folder').length).toBe(folders.length); + }); +}); + +describe('exact-name surfacing — deep candidate pool', () => { + test('an exact basename is never dropped for lower-tier siblings that crowd the limit', () => { + const target = createWorkspaceSearchDocument({ + kind: 'page', + path: 'cloud-collaboration/STORY', + title: 'Quiet collaboration notes', + modifiedTs: 1, + }); + const partials = Array.from({ length: 40 }, (_, n) => + createWorkspaceSearchDocument({ + kind: 'page', + path: `reports/storybook-${n}/storybook-${n}`, + title: `Storybook story story story ${n}`, + modifiedTs: 50 + n, + }), + ); + const results = searchWorkspaceDocuments([...partials, target], 'story', { + intent: 'omnibar', + limit: 10, + }); + expect(results[0]?.document.path).toBe('cloud-collaboration/STORY'); + expect(partials.length).toBeGreaterThan(10); + }); + + test('an exact basename remains findable among many same-named siblings (deep pool)', () => { + const target = createWorkspaceSearchDocument({ + kind: 'file', + path: 'team/quarterly/data.csv', + modifiedTs: 100, + }); + const siblings = Array.from({ length: 80 }, (_, n) => + createWorkspaceSearchDocument({ + kind: 'file', + path: `archive/run-${n}/data.csv`, + modifiedTs: n, + }), + ); + const results = searchWorkspaceDocuments([...siblings, target], 'data.csv', { + intent: 'full_text', + limit: 50, + }); + expect(results.some((r) => r.document.path === 'team/quarterly/data.csv')).toBe(true); + }); + + test('a uniquely-named file ranks first and a matching folder still surfaces (regression)', () => { + const docs = [ + createWorkspaceSearchDocument({ + kind: 'page', + path: 'guides/onboarding', + title: 'Onboarding', + modifiedTs: 5, + }), + createWorkspaceSearchDocument({ kind: 'folder', path: 'guides', modifiedTs: 0 }), + createWorkspaceSearchDocument({ kind: 'file', path: 'assets/diagram.png', modifiedTs: 9 }), + ]; + const unique = searchWorkspaceDocuments(docs, 'diagram.png', { intent: 'omnibar' }); + expect(unique[0]?.document.path).toBe('assets/diagram.png'); + + const folderQuery = searchWorkspaceDocuments(docs, 'guides', { intent: 'omnibar' }); + expect(folderQuery.some((r) => r.document.kind === 'folder')).toBe(true); + }); +}); diff --git a/packages/core/src/search/workspace-search.ts b/packages/core/src/search/workspace-search.ts index 46a5a252..ee13f27f 100644 --- a/packages/core/src/search/workspace-search.ts +++ b/packages/core/src/search/workspace-search.ts @@ -5,6 +5,8 @@ export type WorkspaceSearchKind = 'page' | 'folder' | 'file'; export type WorkspaceSearchIntent = 'omnibar' | 'autocomplete' | 'full_text'; export type WorkspaceSearchScope = WorkspaceSearchKind | 'content'; +export type WorkspaceSearchRanking = 'navigation' | 'relevance'; + export interface WorkspaceSearchDocument { id: string; kind: WorkspaceSearchKind; @@ -36,6 +38,7 @@ export interface WorkspaceSemanticInput { export interface WorkspaceSearchOptions { intent?: WorkspaceSearchIntent; + ranking?: WorkspaceSearchRanking; scopes?: readonly WorkspaceSearchScope[]; limit?: number; semantic?: WorkspaceSemanticInput; @@ -201,6 +204,71 @@ function canonicalKindAdjustment(kind: WorkspaceSearchKind): number { return kind === 'file' ? -FILE_KIND_SCORE_DEMOTION : 0; } +const TIER_DOMINANT_GAP = 1000; + +const NAV_RECENCY_CAP = 50; + +const NAV_BODY_WEIGHT = 1; +const NAV_RECENCY_WEIGHT = 1; + +const NAV_KIND_NUDGE = 0.001; + +function navigationScore( + lexical: number, + fullText: number, + recency: number, + kind: WorkspaceSearchKind, + maxFullText: number, +): number { + const bodyNorm = maxFullText > 0 ? fullText / maxFullText : 0; + const recencyNorm = NAV_RECENCY_CAP > 0 ? recency / NAV_RECENCY_CAP : 0; + const kindNudge = kind === 'file' ? -NAV_KIND_NUDGE : 0; + const secondary = NAV_BODY_WEIGHT * bodyNorm + NAV_RECENCY_WEIGHT * recencyNorm + kindNudge; + return lexical * TIER_DOMINANT_GAP + secondary; +} + +function fullTextScore( + lexical: number, + fullText: number, + recency: number, + kind: WorkspaceSearchKind, +): number { + return lexical + fullText * 20 + recency + canonicalKindAdjustment(kind); +} + +function combinedScore( + ranking: WorkspaceSearchRanking, + lexical: number, + fullText: number, + recency: number, + kind: WorkspaceSearchKind, + maxFullText: number, +): number { + return ranking === 'relevance' + ? fullTextScore(lexical, fullText, recency, kind) + : navigationScore(lexical, fullText, recency, kind, maxFullText); +} + +function resolveRanking( + intent: WorkspaceSearchIntent, + ranking: WorkspaceSearchRanking | undefined, +): WorkspaceSearchRanking { + if (ranking) return ranking; + return intent === 'full_text' ? 'relevance' : 'navigation'; +} + +function maxFullTextScore( + candidates: Iterable, + fullTextScores: ReadonlyMap, +): number { + let max = 0; + for (const document of candidates) { + const value = fullTextScores.get(document.id) ?? 0; + if (value > max) max = value; + } + return max; +} + function recencyScores(documents: readonly WorkspaceSearchDocument[]): Map { const modifiedValues = documents .map((document) => document.modifiedTs) @@ -247,10 +315,10 @@ function toleranceFor(intent: WorkspaceSearchIntent, query: string): number { function finalizeResults( ranked: readonly WorkspaceSearchResult[], - intent: WorkspaceSearchIntent, + ranking: WorkspaceSearchRanking, limit: number, ): WorkspaceSearchResult[] { - if (intent === 'full_text') return ranked.slice(0, limit); + if (ranking === 'relevance') return ranked.slice(0, limit); const selected: WorkspaceSearchResult[] = []; let folders = 0; let files = 0; @@ -282,6 +350,7 @@ export function searchWorkspaceCorpus( options: WorkspaceSearchOptions = {}, ): WorkspaceSearchResult[] { const intent = options.intent ?? 'omnibar'; + const ranking = resolveRanking(intent, options.ranking); const limit = clampLimit(options.limit); const scopes = new Set(options.scopes ?? defaultScopes(intent)); const scopedDocuments = corpus.documents.filter((document) => scopeAllows(document, scopes)); @@ -327,6 +396,7 @@ export function searchWorkspaceCorpus( } if (!options.semantic) { + const maxFullText = maxFullTextScore(candidates.values(), fullTextScores); const ranked = [...candidates.values()] .map((document) => { const lexical = Math.max(0, lexicalScore(document, normalizedQuery)); @@ -334,7 +404,14 @@ export function searchWorkspaceCorpus( const recencyScore = recency.get(document.id) ?? 0; return { document, - score: lexical + fullText * 20 + recencyScore + canonicalKindAdjustment(document.kind), + score: combinedScore( + ranking, + lexical, + fullText, + recencyScore, + document.kind, + maxFullText, + ), signals: { lexical, fullText, recency: recencyScore }, }; }) @@ -342,7 +419,7 @@ export function searchWorkspaceCorpus( if (a.score !== b.score) return b.score - a.score; return a.document.path.localeCompare(b.document.path); }); - return finalizeResults(ranked, intent, limit); + return finalizeResults(ranked, ranking, limit); } return finalizeResults( @@ -352,9 +429,10 @@ export function searchWorkspaceCorpus( fullTextScores, recency, normalizedQuery, + ranking, semantic: options.semantic, }), - intent, + ranking, limit, ); } @@ -383,9 +461,18 @@ function rankWithVector(args: { fullTextScores: ReadonlyMap; recency: ReadonlyMap; normalizedQuery: string; + ranking: WorkspaceSearchRanking; semantic: WorkspaceSemanticInput; }): WorkspaceSearchResult[] { - const { scopedDocuments, candidates, fullTextScores, recency, normalizedQuery, semantic } = args; + const { + scopedDocuments, + candidates, + fullTextScores, + recency, + normalizedQuery, + ranking, + semantic, + } = args; const rrfK = semantic.rrfK ?? DEFAULT_RRF_K; const candidateLimit = semantic.candidateLimit ?? DEFAULT_VECTOR_CANDIDATE_LIMIT; const floor = semantic.similarityFloor ?? DEFAULT_VECTOR_SIMILARITY_FLOOR; @@ -403,15 +490,21 @@ function rankWithVector(args: { } } + const maxFullText = maxFullTextScore(candidates.values(), fullTextScores); + const rows: SemanticRow[] = [...candidates.values()].map((document) => { const lexical = Math.max(0, lexicalScore(document, normalizedQuery)); const fullText = fullTextScores.get(document.id) ?? 0; const recencyScore = recency.get(document.id) ?? 0; const cosine = vectorScores.get(document.id); const qualifies = cosine !== undefined && cosine >= floor; + const score = + lexical > 0 + ? combinedScore(ranking, lexical, fullText, recencyScore, document.kind, maxFullText) + : fullTextScore(lexical, fullText, recencyScore, document.kind); return { document, - score: lexical + fullText * 20 + recencyScore + canonicalKindAdjustment(document.kind), + score, signals: qualifies ? { lexical, fullText, recency: recencyScore, vector: cosine } : { lexical, fullText, recency: recencyScore }, diff --git a/packages/server/src/api-extension.ts b/packages/server/src/api-extension.ts index bd3cae82..212d4ca9 100644 --- a/packages/server/src/api-extension.ts +++ b/packages/server/src/api-extension.ts @@ -174,6 +174,7 @@ import { type WorkspaceSearchCorpus, type WorkspaceSearchDocument, type WorkspaceSearchIntent, + type WorkspaceSearchRanking, type WorkspaceSearchResult, type WorkspaceSearchScope, type WorkspaceSemanticInput, @@ -10317,6 +10318,10 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { return 'omnibar'; } + function parseSearchRanking(value: unknown): WorkspaceSearchRanking | undefined { + return value === 'navigation' || value === 'relevance' ? value : undefined; + } + function parseSearchScopes(value: unknown): WorkspaceSearchScope[] | undefined { const rawScopes = typeof value === 'string' ? value.split(',') : Array.isArray(value) ? value : undefined; @@ -10414,6 +10419,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { async function buildSearchResponse(params: { query: string; intent: WorkspaceSearchIntent; + ranking: WorkspaceSearchRanking | undefined; scopes: WorkspaceSearchScope[] | undefined; limit: number | undefined; semanticParam: boolean | undefined; @@ -10429,6 +10435,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { ); const results = searchWorkspaceCorpus(corpus, params.query, { intent: params.intent, + ranking: params.ranking, scopes: params.scopes, limit: params.limit, semantic: semantic.input, @@ -10626,6 +10633,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { const limit = url.searchParams.get('limit'); const query = url.searchParams.get('query') ?? ''; const intent = parseSearchIntent(url.searchParams.get('intent')); + const ranking = parseSearchRanking(url.searchParams.get('ranking')); const scopes = parseSearchScopes( url.searchParams.get('scope') ?? url.searchParams.get('scopes'), ); @@ -10647,6 +10655,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { const body = await buildSearchResponse({ query, intent, + ranking, scopes, limit: limitNum, semanticParam, @@ -10671,6 +10680,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { async (_req, res, body) => { const query = typeof body.query === 'string' ? body.query : ''; const intent = parseSearchIntent(body.intent); + const ranking = parseSearchRanking(body.ranking); const scopes = parseSearchScopes(body.scopes ?? body.scope); const limit = typeof body.limit === 'number' ? body.limit : undefined; const semanticParam = parseSemanticParam(body.semantic); @@ -10690,6 +10700,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension { const responseBody = await buildSearchResponse({ query, intent, + ranking, scopes, limit, semanticParam, diff --git a/packages/server/src/api-search.test.ts b/packages/server/src/api-search.test.ts index 37b42944..e2ff3a7b 100644 --- a/packages/server/src/api-search.test.ts +++ b/packages/server/src/api-search.test.ts @@ -104,6 +104,36 @@ describe('GET /api/search', () => { } }); + test('ranking param orders the same full_text candidate set: navigation by name, relevance by body', async () => { + const dir = mkdtempSync(join(tmpdir(), 'ok-search-ranking-')); + try { + writeFileSync(join(dir, 'STORY.md'), '# Collaboration\n\nNotes about teamwork.\n', 'utf-8'); + writeFileSync( + join(dir, 'storybook-notes.md'), + '# Storyboard\n\nstory story story story story story story story\n', + 'utf-8', + ); + const index = new Map([ + ['STORY', indexEntry(join(dir, 'STORY.md'), 'markdown')], + ['storybook-notes', indexEntry(join(dir, 'storybook-notes.md'), 'markdown')], + ]); + + const nav = JSON.parse( + (await runSearch(dir, index, '/api/search?query=story&intent=full_text&ranking=navigation')) + .body, + ) as { results: Array<{ path: string }> }; + expect(nav.results[0]?.path).toBe('STORY'); + + const rel = JSON.parse( + (await runSearch(dir, index, '/api/search?query=story&intent=full_text&ranking=relevance')) + .body, + ) as { results: Array<{ path: string }> }; + expect(rel.results[0]?.path).toBe('storybook-notes'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test('returns full-text content matches with snippets', async () => { const dir = mkdtempSync(join(tmpdir(), 'ok-search-')); try {