11import { createLogger } from '@sim/logger'
22import { compareStrings , truncateAtCodePoint } from '@sim/utils/string'
3- import { getNavigation , getPage } from '@/lib/content/catalog'
4- import { findNavigationGroup } from '@/lib/content/navigation'
5- import type { DocsPage } from '@/lib/content/types'
63import { generateSearchEmbedding } from '@/lib/embeddings'
7- import { getSearchIndex } from '@/lib/search/precomputed'
8- import type { SearchResult } from '@/lib/search/types'
4+ import { getSearchIndex , getSearchPage } from '@/lib/search/precomputed'
5+ import type { SearchPage , SearchResult } from '@/lib/search/types'
96import { normalizeSearchText } from '@/lib/search/utils'
107import { DOCS_BASE_URL } from '@/lib/urls'
118
@@ -24,22 +21,16 @@ const STOP_WORDS = new Set([
2421 'the' ,
2522 'to' ,
2623] )
27- const SECTION_LABELS = { docs : 'Documentation' , api : 'API reference' , cli : 'CLI' , mcp : 'MCP' }
24+ const HYBRID_TIMEOUT_MS = 1000
2825let hybridRequests = 0
2926
30- function resultFor ( page : DocsPage , id : string , url : string , heading : string ) : SearchResult {
31- const section = SECTION_LABELS [ page . section ]
32- const group = findNavigationGroup ( getNavigation ( page . section ) , page . url )
27+ function resultFor ( page : SearchPage , id : string , url : string , heading : string ) : SearchResult {
3328 return {
3429 id,
3530 type : 'page' ,
3631 url,
3732 content : heading || page . title ,
38- breadcrumbs : [
39- section ,
40- ...( group && group !== section && group !== page . title ? [ group ] : [ ] ) ,
41- page . title ,
42- ] ,
33+ breadcrumbs : [ ...page . breadcrumbs ] ,
4334 }
4435}
4536
@@ -74,7 +65,7 @@ function lexicalSearch(query: string, limit: number): SearchResult[] {
7465 score += body . includes ( phrase ) ? 12 : 0
7566 const existing = scores . get ( chunk . pageId )
7667 if ( existing && existing . score >= score ) continue
77- const page = getPage ( new URL ( chunk . sourceLink ) . pathname )
68+ const page = getSearchPage ( new URL ( chunk . sourceLink ) . pathname )
7869 if ( ! page ) continue
7970 scores . set ( chunk . pageId , {
8071 score,
@@ -97,76 +88,94 @@ interface DatabaseResult {
9788 metadata : { contentHash ?: string }
9889}
9990
100- async function hybridSearch ( query : string , limit : number , signal ?: AbortSignal ) {
91+ function hybridResults ( lists : DatabaseResult [ ] [ ] , limit : number ) : SearchResult [ ] {
92+ const scores = new Map < string , { score : number ; result : SearchResult } > ( )
93+ for ( const list of lists ) {
94+ const seenPages = new Set < string > ( )
95+ list . forEach ( ( row , index ) => {
96+ let citation : URL
97+ try {
98+ citation = new URL ( row . source_link )
99+ } catch {
100+ return
101+ }
102+ if ( citation . origin !== new URL ( DOCS_BASE_URL ) . origin ) return
103+ const page = getSearchPage ( citation . pathname )
104+ if ( ! page || row . metadata ?. contentHash !== page . contentHash ) return
105+ if ( citation . hash && ! page . headings . includes ( citation . hash . slice ( 1 ) ) ) return
106+ if ( seenPages . has ( page . id ) ) return
107+ seenPages . add ( page . id )
108+ const existing = scores . get ( page . id )
109+ const score = ( existing ?. score ?? 0 ) + 1 / ( 60 + index + 1 )
110+ scores . set ( page . id , {
111+ score,
112+ result : existing ?. result ?? resultFor ( page , row . chunk_id , citation . href , row . header_text ) ,
113+ } )
114+ } )
115+ }
116+ return [ ...scores . values ( ) ]
117+ . sort (
118+ ( left , right ) => right . score - left . score || compareStrings ( left . result . url , right . result . url )
119+ )
120+ . slice ( 0 , limit )
121+ . map ( ( { result } ) => result )
122+ }
123+
124+ async function hybridSearch (
125+ query : string ,
126+ limit : number ,
127+ signal : AbortSignal ,
128+ onResults : ( results : SearchResult [ ] ) => void
129+ ) {
101130 const { searchDatabase } = await import ( '@/lib/search/database' )
131+ if ( signal . aborted ) return [ ]
102132 const sql = searchDatabase ( )
103- const embedding = await generateSearchEmbedding ( query , signal ) . catch ( ( ) => null )
104- if ( signal ?. aborted ) return [ ]
105133 const keyword = sql < DatabaseResult [ ] > `
106134 SELECT chunk_id, source_document, source_link, header_text, metadata
107135 FROM docs_embeddings
108136 WHERE chunk_text_tsv @@ plainto_tsquery('english', ${ query } )
109137 ORDER BY ts_rank(chunk_text_tsv, plainto_tsquery('english', ${ query } )) DESC, source_link
110138 LIMIT ${ limit * 3 }
111139 `
112- const vector = embedding
113- ? sql < DatabaseResult [ ] > `
140+ let vector : typeof keyword | undefined
141+ const sources : DatabaseResult [ ] [ ] = [ [ ] , [ ] ]
142+ async function collect ( source : number , request : PromiseLike < DatabaseResult [ ] > ) {
143+ const rows = await request
144+ if ( signal . aborted ) return
145+ sources [ source ] = rows
146+ onResults ( hybridResults ( sources , limit ) )
147+ }
148+ async function collectVector ( ) {
149+ const embedding = await generateSearchEmbedding ( query , signal ) . catch ( ( ) => null )
150+ if ( ! embedding || signal . aborted ) return
151+ vector = sql < DatabaseResult [ ] > `
114152 SELECT chunk_id, source_document, source_link, header_text, metadata
115153 FROM docs_embeddings
116154 WHERE 1 - (embedding <=> ${ JSON . stringify ( embedding ) } ::vector) >= 0.6
117155 ORDER BY embedding <=> ${ JSON . stringify ( embedding ) } ::vector, source_link
118156 LIMIT ${ limit * 3 }
119157 `
120- : undefined
158+ await collect ( 1 , vector )
159+ }
121160 const cancel = ( ) => {
122161 keyword . cancel ( )
123162 vector ?. cancel ( )
124163 }
125- signal ? .addEventListener ( 'abort' , cancel , { once : true } )
164+ signal . addEventListener ( 'abort' , cancel , { once : true } )
126165 try {
127- const lists = await Promise . allSettled ( [ keyword , vector ?? Promise . resolve ( [ ] ) ] )
128- const scores = new Map < string , { score : number ; result : SearchResult } > ( )
166+ const lists = await Promise . allSettled ( [ collect ( 0 , keyword ) , collectVector ( ) ] )
129167 for ( const [ source , list ] of lists . entries ( ) ) {
130168 if ( list . status === 'rejected' ) {
131- if ( ! signal ? .aborted )
169+ if ( ! signal . aborted )
132170 logger . warn ( 'Hybrid search query unavailable; serving remaining results' , {
133171 source : source === 0 ? 'keyword' : 'vector' ,
134172 error : list . reason ,
135173 } )
136- continue
137174 }
138- const seenPages = new Set < string > ( )
139- list . value . forEach ( ( row , index ) => {
140- let citation : URL
141- try {
142- citation = new URL ( row . source_link )
143- } catch {
144- return
145- }
146- if ( citation . origin !== new URL ( DOCS_BASE_URL ) . origin ) return
147- const page = getPage ( citation . pathname )
148- if ( ! page || row . metadata ?. contentHash !== page . contentHash ) return
149- if ( citation . hash && ! page . headings . some ( ( heading ) => `#${ heading . id } ` === citation . hash ) )
150- return
151- if ( seenPages . has ( page . id ) ) return
152- seenPages . add ( page . id )
153- const existing = scores . get ( page . id )
154- const score = ( existing ?. score ?? 0 ) + 1 / ( 60 + index + 1 )
155- scores . set ( page . id , {
156- score,
157- result : existing ?. result ?? resultFor ( page , row . chunk_id , citation . href , row . header_text ) ,
158- } )
159- } )
160175 }
161- return [ ...scores . values ( ) ]
162- . sort (
163- ( left , right ) =>
164- right . score - left . score || compareStrings ( left . result . url , right . result . url )
165- )
166- . slice ( 0 , limit )
167- . map ( ( { result } ) => result )
176+ return hybridResults ( sources , limit )
168177 } finally {
169- signal ? .removeEventListener ( 'abort' , cancel )
178+ signal . removeEventListener ( 'abort' , cancel )
170179 }
171180}
172181
@@ -182,35 +191,34 @@ export async function searchDocs(
182191 : 10
183192 if ( ! query ) return [ ]
184193 const lexical = lexicalSearch ( query , limit )
185- if ( ! process . env . DATABASE_URL || hybridRequests >= 2 || signal ?. aborted ) return lexical
194+ if ( lexical . length || ! process . env . DATABASE_URL || hybridRequests >= 2 || signal ?. aborted )
195+ return lexical
196+ const controller = new AbortController ( )
197+ const combined = signal ? AbortSignal . any ( [ signal , controller . signal ] ) : controller . signal
198+ let partial : SearchResult [ ] = [ ]
199+ let onAbort : ( ( ) => void ) | undefined
200+ const aborted = new Promise < SearchResult [ ] > ( ( resolve ) => {
201+ onAbort = ( ) => resolve ( partial )
202+ combined . addEventListener ( 'abort' , onAbort , { once : true } )
203+ if ( combined . aborted ) onAbort ( )
204+ } )
205+ const timeout = setTimeout ( ( ) => controller . abort ( ) , HYBRID_TIMEOUT_MS )
186206 hybridRequests += 1
207+ const pending = hybridSearch ( query , limit , combined , ( results ) => {
208+ partial = results
209+ } )
210+ . catch ( ( error ) => {
211+ if ( ! combined . aborted )
212+ logger . warn ( 'Hybrid search unavailable; serving local results' , { error } )
213+ return partial
214+ } )
215+ . finally ( ( ) => {
216+ hybridRequests -= 1
217+ } )
187218 try {
188- const hybrid = await hybridSearch ( query , limit , signal )
189- const results = new Map < string , { score : number ; result : SearchResult } > ( )
190- for ( const list of [ lexical , hybrid ] ) {
191- list . forEach ( ( result , rank ) => {
192- const path = new URL ( result . url ) . pathname
193- const existing = results . get ( path )
194- const title = getPage ( path ) ?. title
195- const exactTitle =
196- title && normalizeSearchText ( title ) === normalizeSearchText ( query ) ? 0.05 : 0
197- results . set ( path , {
198- score : ( existing ?. score ?? exactTitle ) + 1 / ( 60 + rank + 1 ) ,
199- result : existing ?. result ?? result ,
200- } )
201- } )
202- }
203- return [ ...results . values ( ) ]
204- . sort (
205- ( left , right ) =>
206- right . score - left . score || compareStrings ( left . result . url , right . result . url )
207- )
208- . slice ( 0 , limit )
209- . map ( ( { result } ) => result )
210- } catch ( error ) {
211- if ( ! signal ?. aborted ) logger . warn ( 'Hybrid search unavailable; serving local results' , { error } )
212- return lexical
219+ return await Promise . race ( [ pending , aborted ] )
213220 } finally {
214- hybridRequests -= 1
221+ clearTimeout ( timeout )
222+ if ( onAbort ) combined . removeEventListener ( 'abort' , onAbort )
215223 }
216224}
0 commit comments