diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 2b0a8616..ade51b56 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -3,6 +3,7 @@ import { IngestionService } from '../services/ingestion/FileLoader' import { EmbeddingService } from '../services/vector/EmbeddingService' import { UnifiedStore } from '../services/storage/UnifiedStore' import { v4 as uuidv4 } from 'uuid' +import type { SearchResult } from '../types/store' export function registerHandlers(services: { ingestionService: IngestionService @@ -79,4 +80,76 @@ export function registerHandlers(services: { return [] } }) + + ipcMain.handle('fts-search', async (_event, query: string): Promise => { + try { + const results = await unifiedStore.ftsSearch(query, 20) + const normalized = results.map((item) => { + const obj = item as Record + const out: SearchResult = { + id: String(obj.id ?? ''), + text: String(obj.text ?? ''), + filename: String(obj.filename ?? ''), + createdAt: typeof obj.createdAt === 'number' ? (obj.createdAt as number) : undefined, + _score: typeof obj._score === 'number' ? (obj._score as number) : undefined, + _relevance_score: + typeof obj._relevance_score === 'number' ? (obj._relevance_score as number) : undefined + } + return out + }) + console.log('🔎 [FTS-SEARCH] RESULTS:', normalized) + return normalized + } catch (error: any) { + console.error('❌ [FTS-SEARCH] ERROR:', error) + return [] + } + }) + + ipcMain.handle('vector-search', async (_event, query: string): Promise => { + try { + const { data: queryVector } = await embeddingService.embed(query) + const results = await unifiedStore.vectorSearch(queryVector, 20) + const normalized = results.map((item) => { + const obj = item as Record + const out: SearchResult = { + id: String(obj.id ?? ''), + text: String(obj.text ?? ''), + filename: String(obj.filename ?? ''), + createdAt: typeof obj.createdAt === 'number' ? (obj.createdAt as number) : undefined, + _distance: typeof obj._distance === 'number' ? (obj._distance as number) : undefined + } + return out + }) + console.log('🔎 [VECTOR-SEARCH] RESULTS:', normalized) + return normalized + } catch (error: any) { + console.error('❌ [VECTOR-SEARCH] ERROR:', error) + return [] + } + }) + + ipcMain.handle('hybrid-search', async (_event, query: string): Promise => { + try { + const { data: queryVector } = await embeddingService.embed(query) + const results = await unifiedStore.hybridSearch(queryVector, query, 20) + const normalized = results.map((item) => { + const obj = item as Record + const out: SearchResult = { + id: String(obj.id ?? ''), + text: String(obj.text ?? ''), + filename: String(obj.filename ?? ''), + createdAt: typeof obj.createdAt === 'number' ? (obj.createdAt as number) : undefined, + _score: typeof obj._score === 'number' ? (obj._score as number) : undefined, + _relevance_score: + typeof obj._relevance_score === 'number' ? (obj._relevance_score as number) : undefined + } + return out + }) + console.log('🔎 [HYBRID-SEARCH] RESULTS:', normalized) + return normalized + } catch (error: any) { + console.error('❌ [HYBRID-SEARCH] ERROR:', error) + return [] + } + }) } diff --git a/src/main/services/search/HybridSearch.ts b/src/main/services/search/HybridSearch.ts index 73c7e3e2..1c008371 100644 --- a/src/main/services/search/HybridSearch.ts +++ b/src/main/services/search/HybridSearch.ts @@ -1,12 +1,6 @@ import { UnifiedStore } from '../storage/UnifiedStore' import { EmbeddingService } from '../vector/EmbeddingService' - -interface SearchResult { - id: string - score: number - content: string - metadata?: any -} +import type { SearchResult as StoreSearchResult } from '../../types/store' export class HybridSearch { private static instance: HybridSearch @@ -25,13 +19,13 @@ export class HybridSearch { return HybridSearch.instance } - public async search(query: string, limit = 20): Promise { + public async search(query: string, limit = 20): Promise { // 1. Generate embedding const { data: queryVector } = await this.embeddingService.embed(query) // 2. Parallel search const [vectorResults, keywordResults] = await Promise.all([ - this.unifiedStore.search(queryVector, 50), + this.unifiedStore.vectorSearch(queryVector, 50), this.unifiedStore.ftsSearch(query, 50) ]) @@ -42,14 +36,16 @@ export class HybridSearch { return fusedResults.slice(0, limit) } - private rrf(vectorResults: any[], keywordResults: any[], k = 60): SearchResult[] { + private rrf( + vectorResults: StoreSearchResult[], + keywordResults: StoreSearchResult[], + k = 60 + ): StoreSearchResult[] { const scores = new Map() - const docMap = new Map() + const docMap = new Map() // Process Vector Results vectorResults.forEach((res, rank) => { - // LanceDB returns { id, vector, ...rest } - // Ensure we have ID const id = res.id docMap.set(id, res) const score = 1 / (k + rank + 1) @@ -72,12 +68,14 @@ export class HybridSearch { }) return sortedIds.map((id) => { - const doc = docMap.get(id) + const doc = docMap.get(id)! return { id: doc.id, - score: scores.get(id) || 0, - content: doc.content || doc.text, // Handle different field names - metadata: doc + text: doc.text, + filename: doc.filename, + createdAt: doc.createdAt, + _score: doc._score, + _relevance_score: doc._relevance_score } }) } diff --git a/src/preload/index.ts b/src/preload/index.ts index d8f50917..f6d25295 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,11 +1,17 @@ import { contextBridge, ipcRenderer, webUtils } from 'electron' import { electronAPI } from '@electron-toolkit/preload' +import type { SearchResult } from '../main/types/store' // Custom APIs for renderer const api = { ingestFile: (file: File): Promise<{ success: boolean; count?: number; error?: string }> => ipcRenderer.invoke('ingest-file', webUtils.getPathForFile(file), file.name), - search: (query: string): Promise => ipcRenderer.invoke('search', query) + search: (query: string): Promise => ipcRenderer.invoke('search', query), + ftsSearch: (query: string): Promise => ipcRenderer.invoke('fts-search', query), + vectorSearch: (query: string): Promise => + ipcRenderer.invoke('vector-search', query), + hybridSearch: (query: string): Promise => + ipcRenderer.invoke('hybrid-search', query) } // Use `contextBridge` APIs to expose Electron APIs to diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ece188de..04541894 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react' import type { RendererSearchResult } from './types/global' -import { Input, Button, Upload, message, List, Card, Typography } from 'antd' +import { Input, Button, Upload, message, List, Card, Typography, Segmented } from 'antd' import { UploadOutlined } from '@ant-design/icons' const { Paragraph } = Typography @@ -8,12 +8,20 @@ const { Paragraph } = Typography function App(): React.JSX.Element { const [searchResults, setSearchResults] = useState([]) const [loading, setLoading] = useState(false) + const [mode, setMode] = useState<'ftsSearch' | 'vectorSearch' | 'hybridSearch'>('hybridSearch') const handleSearch = async (value: string) => { if (!value.trim()) return setLoading(true) try { - const results = await window.api.search(value) + let results: RendererSearchResult[] = [] + if (mode === 'ftsSearch') { + results = await window.api.ftsSearch(value) + } else if (mode === 'vectorSearch') { + results = await window.api.vectorSearch(value) + } else { + results = await window.api.hybridSearch(value) + } console.log('🔎 [SEARCH] RESULTS APP:', results) setSearchResults(results) } catch (error) { @@ -54,6 +62,16 @@ function App(): React.JSX.Element { + setMode(val as typeof mode)} + /> + ( ID: {item.id} Created: {item.createdAt ? new Date(item.createdAt).toLocaleString() : 'N/A'} - - _score: {typeof item._score === 'number' ? item._score.toFixed(6) : 'N/A'} - - - _relevance_score:{' '} - {typeof item._relevance_score === 'number' - ? item._relevance_score.toFixed(6) - : 'N/A'} - + {mode !== 'vectorSearch' && ( + _score: {typeof item._score === 'number' ? item._score.toFixed(6) : 'N/A'} + )} + {mode === 'hybridSearch' && ( + + _relevance_score:{' '} + {typeof item._relevance_score === 'number' + ? item._relevance_score.toFixed(6) + : 'N/A'} + + )} + {mode === 'vectorSearch' && ( + + _distance: {typeof item._distance === 'number' ? item._distance.toFixed(6) : 'N/A'} + + )} diff --git a/src/renderer/src/types/global.d.ts b/src/renderer/src/types/global.d.ts index 3b5c8651..23b87f13 100644 --- a/src/renderer/src/types/global.d.ts +++ b/src/renderer/src/types/global.d.ts @@ -7,6 +7,7 @@ export interface RendererSearchResult { createdAt?: number _score?: number _relevance_score?: number + _distance?: number } declare global { @@ -15,6 +16,9 @@ declare global { api: { ingestFile: (file: File) => Promise<{ success: boolean; count?: number; error?: string }> search: (query: string) => Promise + ftsSearch: (query: string) => Promise + vectorSearch: (query: string) => Promise + hybridSearch: (query: string) => Promise } } }