feat(搜索): 实现混合搜索功能并添加搜索模式切换

添加 FTS、向量和混合搜索功能
在界面中添加搜索模式选择器
统一搜索结果类型定义
This commit is contained in:
hy committed 2026-01-05 16:11:45 +08:00
1 parent 23f9c52162
commit 1fa981f40b
5 files changed
+136 -29

No files matched your search

+73
View File
@@ -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 []
}
})
}
+15 -17
View File
@@ -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
}
})
}
+7 -1
View File
@@ -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
View File
@@ -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>
+4
View File
@@ -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[]>
}
}
}