@@ -62,6 +62,7 @@ const VECTOR_TABLE = "chunks_vec";
6262const FTS_TABLE = "chunks_fts" ;
6363const EMBEDDING_CACHE_TABLE = "embedding_cache" ;
6464const MEMORY_INDEX_MANAGER_CACHE_KEY = Symbol . for ( "openclaw.memoryIndexManagerCache" ) ;
65+ const HYBRID_KEYWORD_FANOUT_LIMIT = 8 ;
6566export const EMBEDDING_PROBE_CACHE_TTL_MS = 30_000 ;
6667const log = createSubsystemLogger ( "memory" ) ;
6768type MemoryIndexManagerPurpose = "default" | "status" | "cli" ;
@@ -384,24 +385,14 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
384385 const resultSets =
385386 fullQueryResults . length > 0
386387 ? [ fullQueryResults ]
387- : await Promise . all (
388- // Fallback: broaden recall for conversational queries when the
389- // exact AND query is too strict to return any results.
390- ( ( ) => {
391- const keywords = extractKeywords ( cleaned , {
392- ftsTokenizer : this . settings . store . fts . tokenizer ,
393- } ) ;
394- const searchTerms = keywords . length > 0 ? keywords : [ cleaned ] ;
395- return searchTerms . map ( ( term ) =>
396- this . searchKeyword (
397- term ,
398- candidates ,
399- { boostFallbackRanking : true } ,
400- sourceFilterList ,
401- ) . catch ( ( ) => [ ] ) ,
402- ) ;
403- } ) ( ) ,
404- ) ;
388+ : [
389+ await this . searchKeywordsMulti (
390+ cleaned ,
391+ candidates ,
392+ { boostFallbackRanking : true , includeFullQuery : false } ,
393+ sourceFilterList ,
394+ ) ,
395+ ] ;
405396
406397 // Merge and deduplicate results, keeping highest score for each chunk
407398 const seenIds = new Map < string , ( typeof resultSets ) [ 0 ] [ 0 ] > ( ) ;
@@ -427,7 +418,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
427418 // If FTS isn't available, hybrid mode cannot use keyword search; degrade to vector-only.
428419 const keywordResults =
429420 hybrid . enabled && this . fts . enabled && this . fts . available
430- ? await this . searchKeyword ( cleaned , candidates , undefined , sourceFilterList ) . catch ( ( ) => [ ] )
421+ ? await this . searchKeywordsMulti ( cleaned , candidates , undefined , sourceFilterList )
431422 : [ ] ;
432423
433424 const queryVec = await this . embedQueryWithTimeout ( cleaned ) ;
@@ -561,6 +552,51 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
561552 return results . map ( ( entry ) => entry as MemorySearchResult & { id : string ; textScore : number } ) ;
562553 }
563554
555+ private resolveHybridKeywordTerms ( query : string ) : string [ ] {
556+ const keywords = extractKeywords ( query , {
557+ ftsTokenizer : this . settings . store . fts . tokenizer ,
558+ } ) ;
559+ const ranked = keywords
560+ . toSorted ( ( a , b ) => b . length - a . length )
561+ . slice ( 0 , HYBRID_KEYWORD_FANOUT_LIMIT ) ;
562+ return ranked . length > 0 ? ranked : [ query ] ;
563+ }
564+
565+ private async searchKeywordsMulti (
566+ query : string ,
567+ limit : number ,
568+ options ?: { boostFallbackRanking ?: boolean ; includeFullQuery ?: boolean } ,
569+ sourceFilterList ?: MemorySource [ ] ,
570+ ) : Promise < Array < MemorySearchResult & { id : string ; textScore : number } > > {
571+ const terms = this . resolveHybridKeywordTerms ( query ) ;
572+ const includeFullQuery = options ?. includeFullQuery ?? true ;
573+ const searchTerms = includeFullQuery
574+ ? [ query , ...terms . filter ( ( term ) => term !== query ) ]
575+ : terms ;
576+ const resultSets = await Promise . all (
577+ searchTerms . map ( ( term ) =>
578+ this . searchKeyword (
579+ term ,
580+ limit ,
581+ { boostFallbackRanking : options ?. boostFallbackRanking } ,
582+ sourceFilterList ,
583+ ) . catch ( ( ) => [ ] ) ,
584+ ) ,
585+ ) ;
586+
587+ const byId = new Map < string , MemorySearchResult & { id : string ; textScore : number } > ( ) ;
588+ for ( const results of resultSets ) {
589+ for ( const result of results ) {
590+ const existing = byId . get ( result . id ) ;
591+ if ( ! existing || result . score > existing . score ) {
592+ byId . set ( result . id , result ) ;
593+ }
594+ }
595+ }
596+
597+ return [ ...byId . values ( ) ] . toSorted ( ( a , b ) => b . score - a . score ) . slice ( 0 , limit ) ;
598+ }
599+
564600 private mergeHybridResults ( params : {
565601 vector : Array < MemorySearchResult & { id : string } > ;
566602 keyword : Array < MemorySearchResult & { id : string ; textScore : number } > ;
0 commit comments