feat(搜索): 实现混合搜索功能并添加搜索模式切换
添加 FTS、向量和混合搜索功能 在界面中添加搜索模式选择器 统一搜索结果类型定义
This commit is contained in:
1 parent
23f9c52162
commit
1fa981f40b
5 files changed
+136
-29
No files matched your search
@@ -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<SearchResult[]> => {
|
||||
try {
|
||||
const results = await unifiedStore.ftsSearch(query, 20)
|
||||
const normalized = results.map((item) => {
|
||||
const obj = item as Record<string, unknown>
|
||||
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<SearchResult[]> => {
|
||||
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<string, unknown>
|
||||
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<SearchResult[]> => {
|
||||
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<string, unknown>
|
||||
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 []
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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<SearchResult[]> {
|
||||
public async search(query: string, limit = 20): Promise<StoreSearchResult[]> {
|
||||
// 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<string, number>()
|
||||
const docMap = new Map<string, any>()
|
||||
const docMap = new Map<string, StoreSearchResult>()
|
||||
|
||||
// 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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<any[]> => ipcRenderer.invoke('search', query)
|
||||
search: (query: string): Promise<SearchResult[]> => ipcRenderer.invoke('search', query),
|
||||
ftsSearch: (query: string): Promise<SearchResult[]> => ipcRenderer.invoke('fts-search', query),
|
||||
vectorSearch: (query: string): Promise<SearchResult[]> =>
|
||||
ipcRenderer.invoke('vector-search', query),
|
||||
hybridSearch: (query: string): Promise<SearchResult[]> =>
|
||||
ipcRenderer.invoke('hybrid-search', query)
|
||||
}
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
|
||||
+37
-11
@@ -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<RendererSearchResult[]>([])
|
||||
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 {
|
||||
<Button icon={<UploadOutlined />}>Upload Document</Button>
|
||||
</Upload>
|
||||
|
||||
<Segmented
|
||||
options={[
|
||||
{ label: 'Hybrid', value: 'hybridSearch' },
|
||||
{ label: 'FTS', value: 'ftsSearch' },
|
||||
{ label: 'Vector', value: 'vectorSearch' }
|
||||
]}
|
||||
value={mode}
|
||||
onChange={(val) => setMode(val as typeof mode)}
|
||||
/>
|
||||
|
||||
<Input.Search
|
||||
placeholder="Search knowledge base..."
|
||||
enterButton="Search"
|
||||
@@ -67,6 +85,7 @@ function App(): React.JSX.Element {
|
||||
<div className="flex-1 overflow-auto">
|
||||
<List
|
||||
dataSource={searchResults}
|
||||
rowKey="id"
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<Card
|
||||
@@ -79,15 +98,22 @@ function App(): React.JSX.Element {
|
||||
<div className="text-xs text-gray-500 mt-3 flex flex-wrap gap-4">
|
||||
<span>ID: {item.id}</span>
|
||||
<span>Created: {item.createdAt ? new Date(item.createdAt).toLocaleString() : 'N/A'}</span>
|
||||
<span>
|
||||
_score: {typeof item._score === 'number' ? item._score.toFixed(6) : 'N/A'}
|
||||
</span>
|
||||
<span>
|
||||
_relevance_score:{' '}
|
||||
{typeof item._relevance_score === 'number'
|
||||
? item._relevance_score.toFixed(6)
|
||||
: 'N/A'}
|
||||
</span>
|
||||
{mode !== 'vectorSearch' && (
|
||||
<span>_score: {typeof item._score === 'number' ? item._score.toFixed(6) : 'N/A'}</span>
|
||||
)}
|
||||
{mode === 'hybridSearch' && (
|
||||
<span>
|
||||
_relevance_score:{' '}
|
||||
{typeof item._relevance_score === 'number'
|
||||
? item._relevance_score.toFixed(6)
|
||||
: 'N/A'}
|
||||
</span>
|
||||
)}
|
||||
{mode === 'vectorSearch' && (
|
||||
<span>
|
||||
_distance: {typeof item._distance === 'number' ? item._distance.toFixed(6) : 'N/A'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</List.Item>
|
||||
|
||||
Vendored
+4
@@ -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<RendererSearchResult[]>
|
||||
ftsSearch: (query: string) => Promise<RendererSearchResult[]>
|
||||
vectorSearch: (query: string) => Promise<RendererSearchResult[]>
|
||||
hybridSearch: (query: string) => Promise<RendererSearchResult[]>
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user