From 219b8521f93e0ed6602091a5bd55b0cf55fc65d6 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Sat, 27 Jun 2026 22:09:26 +0800 Subject: [PATCH 1/2] feat: optimize vector search with HyDE, semantic reranking, and structured embeddings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace fake 'AI reranking' (keyword substring matching) with true LLM-based semantic reranking following the gist reranking pattern - Add HyDE (Hypothetical Document Embedding) query preprocessing for better recall on short/Chinese/ambiguous queries, with 5s timeout and AbortSignal cleanup - Restructure buildEmbeddingText with field labels (Repository:, Description:, Topics:, etc.) and dedup logic - Add lightweight keyword boost for vector search results (name/description/tag exact match bonus) - Make search threshold, topK, HyDE toggle, and reranking toggle user-configurable in VectorSearchSettings UI - Add EMBEDDING_FORMAT_VERSION tracking to force re-index when embedding text format changes - Fix greedy regex in both gist and semantic reranking JSON parsing (.* → .*?) - Fix variable shadowing of translation function t in keyword boost lambdas - Bump default threshold from 0.3 to 0.35 Co-Authored-By: Claude Fable 5 --- cloudflare-worker/src/index.ts | 2 +- src/components/SearchBar.tsx | 68 +++++++-- .../settings/VectorSearchSettings.tsx | 138 +++++++++++++++++- src/services/aiService.ts | 91 +++++++++++- src/services/vectorSearchService.ts | 58 ++++++-- src/store/useAppStore.ts | 7 +- src/types/index.ts | 7 + 7 files changed, 336 insertions(+), 35 deletions(-) diff --git a/cloudflare-worker/src/index.ts b/cloudflare-worker/src/index.ts index 67aa8483..043edf9e 100644 --- a/cloudflare-worker/src/index.ts +++ b/cloudflare-worker/src/index.ts @@ -68,7 +68,7 @@ export default { // POST /query — 向量相似度查询 if (request.method === 'POST' && url.pathname === '/query') { - const { vector, topK = 20, threshold = 0.3 } = (await request.json()) as QueryRequest; + const { vector, topK = 20, threshold = 0.35 } = (await request.json()) as QueryRequest; if (!Array.isArray(vector) || vector.length === 0) { return jsonResponse({ success: false, error: 'vector array required' }, 400); } diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index dc0b6c8a..a0636222 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -539,17 +539,63 @@ export const SearchBar: React.FC = () => { const embeddingClient = new EmbeddingClient(activeEmbConfig); const vectorService = new VectorSearchService(vsConfig); - // 1. 前端调用 Embedding API 生成查询向量 + // 1. HyDE 查询预处理:用 LLM 生成理想仓库描述再嵌入(可选,5 秒超时降级) + let embeddingQuery = searchQuery; + const hydeConfig = aiConfigs.find(config => config.id === activeAIConfig); + if (vsConfig.enableHyDE !== false && hydeConfig) { + const hydeAbort = new AbortController(); + let hydeTimer: ReturnType | null = null; + try { + setSearchPhase(t('AI 分析查询...', 'AI analyzing query...')); + const { AIService } = await import('../services/aiService'); + const hydeService = new AIService(hydeConfig, language); + embeddingQuery = await Promise.race([ + hydeService.generateHyDEQuery(searchQuery, hydeAbort.signal), + new Promise((resolve) => { + hydeTimer = setTimeout(() => { + hydeAbort.abort(); + resolve(searchQuery); + }, 5000); + }), + ]); + if (embeddingQuery !== searchQuery) { + console.log('🔮 HyDE generated:', embeddingQuery.slice(0, 100)); + } + } catch (hydeError) { + console.warn('HyDE failed, using raw query:', hydeError); + embeddingQuery = searchQuery; + } finally { + if (hydeTimer) clearTimeout(hydeTimer); + } + } + + // 2. 前端调用 Embedding API 生成查询向量 setSearchPhase(t('生成查询向量...', 'Generating query vector...')); - const queryVectors = await embeddingClient.embed([searchQuery], 'query'); + const queryVectors = await embeddingClient.embed([embeddingQuery], 'query'); if (queryVectors && queryVectors.length > 0) { // 2. 前端将查询向量发送到 Worker setSearchPhase(t('检索向量库...', 'Searching vector index...')); - const vectorResults = await vectorService.query(queryVectors[0], { topK: 30, threshold: 0.3 }); + const vectorResults = await vectorService.query(queryVectors[0], { + topK: vsConfig.searchTopK ?? 30, + threshold: vsConfig.searchThreshold ?? 0.35, + }); if (vectorResults.length > 0) { - // 3. 从本地仓库数据中取出匹配结果,按相似度排序 - const scoreMap = new Map(vectorResults.map(r => [r.id, r.score])); + // 3. 轻量关键词加分:精确匹配的字段给予分数微调 + const queryLower = searchQuery.toLowerCase(); + const boostedResults = vectorResults.map(r => { + let bonus = 0; + const name = (r.metadata.full_name || '').toLowerCase(); + const desc = (r.metadata.description || '').toLowerCase(); + const tags = (r.metadata.tags || []).map(tag => tag.toLowerCase()); + if (name.includes(queryLower)) bonus += 0.05; + if (desc.includes(queryLower)) bonus += 0.03; + if (tags.some(tag => tag.includes(queryLower))) bonus += 0.02; + return { ...r, score: r.score + bonus }; + }); + + // 4. 从本地仓库数据中取出匹配结果,按相似度排序 + const scoreMap = new Map(boostedResults.map(r => [r.id, r.score])); const scoredRepos = filtered .filter(repo => scoreMap.has(String(repo.id))) .map(repo => ({ @@ -560,20 +606,20 @@ export const SearchBar: React.FC = () => { .map(item => item.repo); if (scoredRepos.length > 0) { - // 4. AI 校验:用 LLM 对向量搜索结果进行二次排序 + // 4. AI 语义重排序:用 LLM 对向量搜索结果做真正的语义排序 let reranked = scoredRepos; let rerankSucceeded = false; const rerankConfig = aiConfigs.find(config => config.id === activeAIConfig); - if (rerankConfig) { + if (rerankConfig && vsConfig.enableReranking !== false) { try { - setSearchPhase(t('AI 校验排序...', 'AI reranking...')); + setSearchPhase(t('AI 语义重排序...', 'AI semantic reranking...')); const { AIService } = await import('../services/aiService'); const rerankService = new AIService(rerankConfig, language); - reranked = await rerankService.searchRepositoriesWithReranking(scoredRepos, searchQuery); + reranked = await rerankService.searchRepositoriesWithSemanticReranking(scoredRepos, searchQuery); rerankSucceeded = true; - console.log('🤖 AI reranked results:', reranked.length); + console.log('🤖 AI semantically reranked results:', reranked.length); } catch (rerankError) { - console.warn('AI reranking failed, using vector order:', rerankError); + console.warn('AI semantic reranking failed, using vector order:', rerankError); } } diff --git a/src/components/settings/VectorSearchSettings.tsx b/src/components/settings/VectorSearchSettings.tsx index 69060678..a278abd2 100644 --- a/src/components/settings/VectorSearchSettings.tsx +++ b/src/components/settings/VectorSearchSettings.tsx @@ -17,6 +17,7 @@ import { EmbeddingClient, VectorSearchService, indexAllRepos, + EMBEDDING_FORMAT_VERSION, } from '../../services/vectorSearchService'; import { GitHubApiService } from '../../services/githubApi'; import type { EmbeddingApiType, EmbeddingConfig } from '../../types'; @@ -79,6 +80,12 @@ export const VectorSearchSettings: React.FC = ({ t }) const [formIndexMode, setFormIndexMode] = useState<'description' | 'readme'>(vectorSearchConfig.indexMode || 'readme'); const [formReadmeMaxChars, setFormReadmeMaxChars] = useState(vectorSearchConfig.readmeMaxChars || 6000); + // Search parameters state + const [formSearchThreshold, setFormSearchThreshold] = useState(vectorSearchConfig.searchThreshold ?? 0.35); + const [formSearchTopK, setFormSearchTopK] = useState(vectorSearchConfig.searchTopK ?? 30); + const [formEnableHyDE, setFormEnableHyDE] = useState(vectorSearchConfig.enableHyDE ?? true); + const [formEnableReranking, setFormEnableReranking] = useState(vectorSearchConfig.enableReranking ?? true); + // Test state const [testingEmbedding, setTestingEmbedding] = useState(false); const [embeddingTestResult, setEmbeddingTestResult] = useState<{ success: boolean; dimensions: number; error?: string } | null>(null); @@ -145,10 +152,15 @@ export const VectorSearchSettings: React.FC = ({ t }) embeddingConfigId: activeEmbeddingConfig || '', indexMode: formIndexMode, readmeMaxChars: formReadmeMaxChars, + searchThreshold: formSearchThreshold, + searchTopK: formSearchTopK, + enableHyDE: formEnableHyDE, + enableReranking: formEnableReranking, + embeddingFormatVersion: EMBEDDING_FORMAT_VERSION, }); setWorkerSaved(true); setTimeout(() => setWorkerSaved(false), 2000); - }, [formWorkerUrl, formAuthToken, formIndexMode, formReadmeMaxChars, activeEmbeddingConfig, setVectorSearchConfig]); + }, [formWorkerUrl, formAuthToken, formIndexMode, formReadmeMaxChars, formSearchThreshold, formSearchTopK, formEnableHyDE, formEnableReranking, activeEmbeddingConfig, setVectorSearchConfig]); const handleTestEmbedding = useCallback(async () => { setTestingEmbedding(true); @@ -319,7 +331,7 @@ export const VectorSearchSettings: React.FC = ({ t }) } finally { setAbortController(null); } - }, [createClients, formIndexMode, formReadmeMaxChars, formDimensions, updateRepositoriesMetadata, setVectorSearchStatus, setVectorIndexingState]); + }, [createClients, formIndexMode, formReadmeMaxChars, formDimensions, updateRepositoriesMetadata, setVectorSearchStatus, setVectorIndexingState, vectorSearchConfig.embeddingFormatVersion]); const handleIncrementalIndex = useCallback(async () => { const clients = createClients(); @@ -351,6 +363,8 @@ export const VectorSearchSettings: React.FC = ({ t }) indexMode: formIndexMode, readmeMaxChars: formReadmeMaxChars, incremental: true, + formatVersion: vectorSearchConfig.embeddingFormatVersion, + currentFormatVersion: EMBEDDING_FORMAT_VERSION, onRepoIndexed: (repoId) => { stampedRepoIds.push(repoId); if (stampedRepoIds.length % 32 === 0) { @@ -406,7 +420,7 @@ export const VectorSearchSettings: React.FC = ({ t }) } finally { setAbortController(null); } - }, [createClients, formIndexMode, formReadmeMaxChars, formDimensions, updateRepositoriesMetadata, setVectorSearchStatus, setVectorIndexingState]); + }, [createClients, formIndexMode, formReadmeMaxChars, formDimensions, updateRepositoriesMetadata, setVectorSearchStatus, setVectorIndexingState, vectorSearchConfig.embeddingFormatVersion]); const handleAbortIndexing = useCallback(() => { abortController?.abort(); @@ -924,10 +938,120 @@ export const VectorSearchSettings: React.FC = ({ t }) )} - {/* Section 5: Delete Index */} -
+ {/* Section 5: Search Parameters */} +

+ {t('搜索参数', 'Search Parameters')} +

+ + {/* Similarity Threshold */} +
+ +
+ setFormSearchThreshold(parseFloat(e.target.value))} + className="flex-1" + /> + + {formSearchThreshold.toFixed(2)} + +
+

+ {t('越高越严格,结果越少但更精确;越低越宽松,召回更多但可能有噪音', 'Higher = stricter, fewer but more precise results; Lower = more recall but may include noise')} +

+
+ + {/* Top K */} +
+ + setFormSearchTopK(Math.max(5, Math.min(50, parseInt(e.target.value) || 30)))} + min={5} + max={50} + className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-brand-indigo focus:border-transparent" + /> +

+ {t('向量检索返回的最大结果数,越多召回越广但 LLM 重排序成本越高', 'Max results from vector search. More = wider recall but higher LLM reranking cost')} +

+
+ + {/* HyDE Toggle */} +
+
+
+ {t('HyDE 查询预处理', 'HyDE Query Preprocessing')} +
+

+ {t('让 AI 生成理想仓库描述再搜索,提升短查询和中文查询的召回率', 'AI generates ideal repo description before searching, improves recall for short/Chinese queries')} +

+
+ +
+ + {/* Reranking Toggle */} +
+
+
+ {t('LLM 语义重排序', 'LLM Semantic Reranking')} +
+

+ {t('用 LLM 对向量搜索结果做语义排序,显著提升排序质量', 'LLM reranks vector results by semantic relevance, significantly improves ranking quality')} +

+
+ +
+ + {/* Save */} + +
+ + {/* Section 6: Delete Index */} +
+

+ {t('删除索引', 'Delete Index')}

@@ -961,14 +1085,14 @@ export const VectorSearchSettings: React.FC = ({ t })

- {/* Section 6: Deploy Guide */} + {/* Section 7: Deploy Guide */}