Skip to content

Commit 1083bfa

Browse files
committed
feat(search): return human-readable label with every SearchResult
Add `label` field to SearchResult so search responses are self-contained and the UI can render results without a follow-up fetch for each id. - types: SearchResult.label, SearchConfig.labelColumn - lib/search: hybridSearch SQL selects p.<labelColumn> in both FTS and vec branches; carries label through RRF fusion (FTS label takes priority on collision) - stores: each passes its labelColumn — knowledge/docs='title', code='name', tasks='title', skills='title', epics='title' - files: separate non-shared search path uses file_path as label - tests: SearchConfig fixture updated; existing search assertions hold - ui/entities: search APIs typed as { id, label, score } instead of Entity & { score } — drops dependency on full entity shape - ui/pages: search/code/docs/files/knowledge/skills render label directly; removes per-result entity fetch
1 parent a27a78b commit 1083bfa

25 files changed

Lines changed: 133 additions & 127 deletions

File tree

src/store/sqlite/lib/search.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export interface SearchConfig {
4141
parentTable: string;
4242
/** Column to join parent table to FTS rowid (usually 'id') */
4343
parentIdColumn: string;
44+
/** Column on parent table to expose as a human-readable label (title / name) */
45+
labelColumn: string;
4446
/** Optional extra SQL appended to the JOIN condition on parent table (e.g. "AND p.kind = 'file'") */
4547
extraJoinCondition?: string;
4648
}
@@ -65,14 +67,15 @@ export function hybridSearch(
6567
const maxResults = query.maxResults ?? 20;
6668
const minScore = query.minScore ?? 0;
6769

68-
let ftsRanked: Array<{ id: number; rn: number }> = [];
69-
let vecRanked: Array<{ id: number; rn: number }> = [];
70+
let ftsRanked: Array<{ id: number; rn: number; label: string }> = [];
71+
let vecRanked: Array<{ id: number; rn: number; label: string }> = [];
7072

7173
// Validate config identifiers to prevent SQL injection
7274
assertIdentifier(config.ftsTable, 'ftsTable');
7375
assertIdentifier(config.vecTable, 'vecTable');
7476
assertIdentifier(config.parentTable, 'parentTable');
7577
assertIdentifier(config.parentIdColumn, 'parentIdColumn');
78+
assertIdentifier(config.labelColumn, 'labelColumn');
7679

7780
const extraJoin = config.extraJoinCondition ?? '';
7881

@@ -82,14 +85,14 @@ export function hybridSearch(
8285
if (!escaped && mode === 'keyword') return [];
8386
if (escaped) {
8487
const rows = db.prepare(`
85-
SELECT p.${config.parentIdColumn} AS id, ROW_NUMBER() OVER (ORDER BY rank) AS rn
88+
SELECT p.${config.parentIdColumn} AS id, p.${config.labelColumn} AS label, ROW_NUMBER() OVER (ORDER BY rank) AS rn
8689
FROM ${config.ftsTable} fts
8790
JOIN ${config.parentTable} p ON p.${config.parentIdColumn} = fts.rowid AND p.project_id = ? ${extraJoin}
8891
WHERE ${config.ftsTable} MATCH ?
8992
LIMIT ?
90-
`).all(projectId, escaped, topK) as Array<{ id: bigint; rn: bigint }>;
93+
`).all(projectId, escaped, topK) as Array<{ id: bigint; label: string | null; rn: bigint }>;
9194

92-
ftsRanked = rows.map(r => ({ id: num(r.id), rn: num(r.rn) }));
95+
ftsRanked = rows.map(r => ({ id: num(r.id), rn: num(r.rn), label: r.label ?? '' }));
9396
}
9497
}
9598

@@ -100,41 +103,44 @@ export function hybridSearch(
100103
const vecK = topK * 3;
101104

102105
const rows = db.prepare(`
103-
SELECT v.rowid AS id, v.distance
106+
SELECT v.rowid AS id, p.${config.labelColumn} AS label, v.distance
104107
FROM ${config.vecTable} v
105108
JOIN ${config.parentTable} p ON p.${config.parentIdColumn} = v.rowid AND p.project_id = ? ${extraJoin}
106109
WHERE v.embedding MATCH ? AND v.k = ?
107-
`).all(projectId, embeddingBuf, vecK) as Array<{ id: bigint; distance: number }>;
110+
`).all(projectId, embeddingBuf, vecK) as Array<{ id: bigint; label: string | null; distance: number }>;
108111

109-
vecRanked = rows.slice(0, topK).map((r, i) => ({ id: num(r.id), rn: i + 1 }));
112+
vecRanked = rows.slice(0, topK).map((r, i) => ({ id: num(r.id), rn: i + 1, label: r.label ?? '' }));
110113
}
111114

112115
// Single-mode: return directly with normalized scores
113116
if (mode === 'keyword') {
114117
return ftsRanked
115-
.map(r => ({ id: r.id, score: 1 / (RRF_K + r.rn) }))
118+
.map(r => ({ id: r.id, score: 1 / (RRF_K + r.rn), label: r.label }))
116119
.filter(r => r.score >= minScore)
117120
.slice(0, maxResults);
118121
}
119122

120123
if (mode === 'vector') {
121124
return vecRanked
122-
.map(r => ({ id: r.id, score: 1 / (RRF_K + r.rn) }))
125+
.map(r => ({ id: r.id, score: 1 / (RRF_K + r.rn), label: r.label }))
123126
.filter(r => r.score >= minScore)
124127
.slice(0, maxResults);
125128
}
126129

127130
// Hybrid: RRF fusion
128131
const scores = new Map<number, number>();
132+
const labels = new Map<number, string>();
129133
for (const r of ftsRanked) {
130134
scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (RRF_K + r.rn));
135+
labels.set(r.id, r.label);
131136
}
132137
for (const r of vecRanked) {
133138
scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (RRF_K + r.rn));
139+
if (!labels.has(r.id)) labels.set(r.id, r.label);
134140
}
135141

136142
return [...scores.entries()]
137-
.map(([id, score]) => ({ id, score }))
143+
.map(([id, score]) => ({ id, score, label: labels.get(id) ?? '' }))
138144
.filter(r => r.score >= minScore)
139145
.sort((a, b) => b.score - a.score)
140146
.slice(0, maxResults);

src/store/sqlite/stores/code.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import * as path from 'path';
1515
const GRAPH = 'code';
1616

1717
const SEARCH_CONFIG: SearchConfig = {
18-
ftsTable: 'code_fts', vecTable: 'code_vec', parentTable: 'code', parentIdColumn: 'id',
18+
ftsTable: 'code_fts', vecTable: 'code_vec', parentTable: 'code', parentIdColumn: 'id', labelColumn: 'name',
1919
};
2020

2121
export class SqliteCodeStore implements CodeStore {

src/store/sqlite/stores/docs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { hybridSearch, SearchConfig } from '../lib/search';
1414
const GRAPH = 'docs';
1515

1616
const SEARCH_CONFIG: SearchConfig = {
17-
ftsTable: 'docs_fts', vecTable: 'docs_vec', parentTable: 'docs', parentIdColumn: 'id',
17+
ftsTable: 'docs_fts', vecTable: 'docs_vec', parentTable: 'docs', parentIdColumn: 'id', labelColumn: 'title',
1818
};
1919

2020
export class SqliteDocsStore implements DocsStore {

src/store/sqlite/stores/epics.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const GRAPH = 'epics';
2323
const ORDER_GAP = 1000;
2424

2525
const SEARCH_CONFIG: SearchConfig = {
26-
ftsTable: 'epics_fts', vecTable: 'epics_vec', parentTable: 'epics', parentIdColumn: 'id',
26+
ftsTable: 'epics_fts', vecTable: 'epics_vec', parentTable: 'epics', parentIdColumn: 'id', labelColumn: 'title',
2727
};
2828

2929
export class SqliteEpicsStore implements EpicsStore {

src/store/sqlite/stores/files.ts

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -219,12 +219,12 @@ export class SqliteFilesStore implements FilesStore {
219219
if (mode === 'keyword' && query.text) {
220220
// Fallback: LIKE-based search on file_path
221221
const rows = this.db.prepare(`
222-
SELECT id FROM files WHERE project_id = ? AND file_path LIKE ? ESCAPE '\\' AND kind = 'file'
222+
SELECT id, file_path FROM files WHERE project_id = ? AND file_path LIKE ? ESCAPE '\\' AND kind = 'file'
223223
ORDER BY file_path ASC LIMIT ?
224-
`).all(this.projectId, `%${likeEscape(query.text)}%`, maxResults) as Array<{ id: bigint }>;
224+
`).all(this.projectId, `%${likeEscape(query.text)}%`, maxResults) as Array<{ id: bigint; file_path: string }>;
225225

226226
return rows
227-
.map((r, i) => ({ id: num(r.id), score: 1 / (60 + i + 1) }))
227+
.map((r, i) => ({ id: num(r.id), score: 1 / (60 + i + 1), label: r.file_path }))
228228
.filter(r => r.score >= minScore);
229229
}
230230

@@ -234,50 +234,57 @@ export class SqliteFilesStore implements FilesStore {
234234
const topK = query.topK ?? 50;
235235

236236
const rows = this.db.prepare(`
237-
SELECT v.rowid AS id, v.distance
237+
SELECT v.rowid AS id, p.file_path, v.distance
238238
FROM files_vec v
239239
JOIN files p ON p.id = v.rowid AND p.project_id = ? AND p.kind = 'file'
240240
WHERE v.embedding MATCH ? AND v.k = ?
241-
`).all(this.projectId, embeddingBuf, topK * 3) as Array<{ id: bigint; distance: number }>;
241+
`).all(this.projectId, embeddingBuf, topK * 3) as Array<{ id: bigint; file_path: string; distance: number }>;
242242

243243
return rows.slice(0, maxResults)
244-
.map((r, i) => ({ id: num(r.id), score: 1 / (60 + i + 1) }))
244+
.map((r, i) => ({ id: num(r.id), score: 1 / (60 + i + 1), label: r.file_path }))
245245
.filter(r => r.score >= minScore);
246246
}
247247

248248
// Hybrid: combine LIKE + vector
249249
if (mode === 'hybrid') {
250-
const likeResults: Array<{ id: number; rn: number }> = [];
251-
const vecResults: Array<{ id: number; rn: number }> = [];
250+
const likeResults: Array<{ id: number; rn: number; label: string }> = [];
251+
const vecResults: Array<{ id: number; rn: number; label: string }> = [];
252252

253253
if (query.text) {
254254
const rows = this.db.prepare(`
255-
SELECT id FROM files WHERE project_id = ? AND file_path LIKE ? ESCAPE '\\' AND kind = 'file'
255+
SELECT id, file_path FROM files WHERE project_id = ? AND file_path LIKE ? ESCAPE '\\' AND kind = 'file'
256256
ORDER BY file_path ASC LIMIT ?
257-
`).all(this.projectId, `%${likeEscape(query.text)}%`, query.topK ?? 50) as Array<{ id: bigint }>;
258-
rows.forEach((r, i) => likeResults.push({ id: num(r.id), rn: i + 1 }));
257+
`).all(this.projectId, `%${likeEscape(query.text)}%`, query.topK ?? 50) as Array<{ id: bigint; file_path: string }>;
258+
rows.forEach((r, i) => likeResults.push({ id: num(r.id), rn: i + 1, label: r.file_path }));
259259
}
260260

261261
if (query.embedding) {
262262
const embeddingBuf = Buffer.from(new Float32Array(query.embedding).buffer);
263263
const topK = query.topK ?? 50;
264264
const rows = this.db.prepare(`
265-
SELECT v.rowid AS id, v.distance
265+
SELECT v.rowid AS id, p.file_path, v.distance
266266
FROM files_vec v
267267
JOIN files p ON p.id = v.rowid AND p.project_id = ? AND p.kind = 'file'
268268
WHERE v.embedding MATCH ? AND v.k = ?
269-
`).all(this.projectId, embeddingBuf, topK * 3) as Array<{ id: bigint; distance: number }>;
270-
rows.slice(0, topK).forEach((r, i) => vecResults.push({ id: num(r.id), rn: i + 1 }));
269+
`).all(this.projectId, embeddingBuf, topK * 3) as Array<{ id: bigint; file_path: string; distance: number }>;
270+
rows.slice(0, topK).forEach((r, i) => vecResults.push({ id: num(r.id), rn: i + 1, label: r.file_path }));
271271
}
272272

273273
// RRF fusion
274274
const K = 60;
275275
const scores = new Map<number, number>();
276-
for (const r of likeResults) scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (K + r.rn));
277-
for (const r of vecResults) scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (K + r.rn));
276+
const labels = new Map<number, string>();
277+
for (const r of likeResults) {
278+
scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (K + r.rn));
279+
labels.set(r.id, r.label);
280+
}
281+
for (const r of vecResults) {
282+
scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (K + r.rn));
283+
if (!labels.has(r.id)) labels.set(r.id, r.label);
284+
}
278285

279286
return [...scores.entries()]
280-
.map(([id, score]) => ({ id, score }))
287+
.map(([id, score]) => ({ id, score, label: labels.get(id) ?? '' }))
281288
.filter(r => r.score >= minScore)
282289
.sort((a, b) => b.score - a.score)
283290
.slice(0, maxResults);

src/store/sqlite/stores/knowledge.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const SEARCH_CONFIG: SearchConfig = {
2424
vecTable: 'knowledge_vec',
2525
parentTable: 'knowledge',
2626
parentIdColumn: 'id',
27+
labelColumn: 'title',
2728
};
2829

2930
export class SqliteKnowledgeStore implements KnowledgeStore {

src/store/sqlite/stores/skills.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { hybridSearch, SearchConfig } from '../lib/search';
2020
const GRAPH = 'skills';
2121

2222
const SEARCH_CONFIG: SearchConfig = {
23-
ftsTable: 'skills_fts', vecTable: 'skills_vec', parentTable: 'skills', parentIdColumn: 'id',
23+
ftsTable: 'skills_fts', vecTable: 'skills_vec', parentTable: 'skills', parentIdColumn: 'id', labelColumn: 'title',
2424
};
2525

2626
export class SqliteSkillsStore implements SkillsStore {

src/store/sqlite/stores/tasks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const GRAPH_TASKS = 'tasks';
2323
const ORDER_GAP = 1000;
2424

2525
const TASK_SEARCH_CONFIG: SearchConfig = {
26-
ftsTable: 'tasks_fts', vecTable: 'tasks_vec', parentTable: 'tasks', parentIdColumn: 'id',
26+
ftsTable: 'tasks_fts', vecTable: 'tasks_vec', parentTable: 'tasks', parentIdColumn: 'id', labelColumn: 'title',
2727
};
2828

2929
const TERMINAL_STATUSES = new Set<string>(['done', 'cancelled']);

src/store/types/common.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export interface SearchQuery {
2626
export interface SearchResult {
2727
id: number;
2828
score: number;
29+
/** Human-readable label (title / name / file_path) returned by the search query */
30+
label: string;
2931
}
3032

3133
// ---------------------------------------------------------------------------

src/tests/store/sqlite/search-edge-cases.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ describe('hybridSearch edge cases', () => {
2828
vecTable: 'test_vec',
2929
parentTable: 'test_items',
3030
parentIdColumn: 'id',
31+
labelColumn: 'title',
3132
};
3233
const projectId = 1;
3334

0 commit comments

Comments
 (0)