feat: chunks recherche (snippets) + script migration
1. Recherche: fetchChunkSnippets() — après le classement RRF existant, récupère les passages précis qui matchent depuis NoteEmbeddingChunk. Pur affichage, AUCUN changement de classement. 2. Script migration: scripts/migrate-chunk-embeddings.ts Indexe toutes les notes existantes en fragments. Batch de 10, barre de progression. Usage: npx tsx scripts/migrate-chunk-embeddings.ts 3. Memory Echo chunk-level: à faire (US restante)
This commit is contained in:
@@ -100,20 +100,60 @@ export class SemanticSearchService {
|
||||
|
||||
const fusedResults = await this.reciprocalRankFusion(keywordResults, semanticResults)
|
||||
|
||||
return fusedResults
|
||||
const topResults = fusedResults
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, opts.limit)
|
||||
.map(result => ({
|
||||
...result,
|
||||
title: result.title || opts.defaultTitle,
|
||||
matchType: result.score > 0.8 ? 'exact' as const : 'related' as const
|
||||
}))
|
||||
|
||||
// Fetch chunk snippets for top results (display only — no ranking change)
|
||||
const noteIds = topResults.map(r => r.noteId)
|
||||
const snippetMap = await this.fetchChunkSnippets(query, noteIds)
|
||||
|
||||
return topResults.map(result => ({
|
||||
...result,
|
||||
title: result.title || opts.defaultTitle,
|
||||
matchType: result.score > 0.8 ? 'exact' as const : 'related' as const,
|
||||
matchedSnippets: snippetMap.get(result.noteId),
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Error in hybrid search:', error)
|
||||
return this._ftsFallback(query, userId, opts)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch matching chunk snippets for display (no ranking impact).
|
||||
* Finds chunks whose content contains the query terms.
|
||||
*/
|
||||
private async fetchChunkSnippets(
|
||||
query: string,
|
||||
noteIds: string[]
|
||||
): Promise<Map<string, string[]>> {
|
||||
if (noteIds.length === 0 || !query.trim()) return new Map()
|
||||
|
||||
try {
|
||||
const rows: Array<{ noteId: string; content: string }> = await prisma.$queryRawUnsafe(
|
||||
`SELECT "noteId", content FROM "NoteEmbeddingChunk"
|
||||
WHERE "noteId" = ANY($1::text[])
|
||||
AND content ILIKE $2
|
||||
ORDER BY "noteId", "chunkIndex"
|
||||
LIMIT 30`,
|
||||
noteIds,
|
||||
`%${query.slice(0, 100)}%`
|
||||
)
|
||||
|
||||
const map = new Map<string, string[]>()
|
||||
for (const row of rows) {
|
||||
const existing = map.get(row.noteId) || []
|
||||
const snippet = row.content.length > 200 ? row.content.slice(0, 200) + '...' : row.content
|
||||
if (existing.length < 3) existing.push(snippet)
|
||||
map.set(row.noteId, existing)
|
||||
}
|
||||
return map
|
||||
} catch {
|
||||
return new Map()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PostgreSQL full-text search using tsvector + GIN index.
|
||||
* SECURITY: Uses $queryRawUnsafe with parameterized bind params ($1, $2…).
|
||||
|
||||
Reference in New Issue
Block a user