@@ -3,7 +3,7 @@ import crypto from 'crypto';
33import { embed } from '../services/embedders/interface.js' ;
44import {
55 upsertPoint , searchPoints , updatePointPayload ,
6- findByPayload , computeEffectiveConfidence , getPoint ,
6+ findByPayload , computeEffectiveConfidence , getPoint , getPoints ,
77} from '../services/qdrant.js' ;
88import {
99 createEvent , upsertFact , upsertStatus , listEvents , listFacts , listStatuses , isStoreAvailable ,
@@ -13,6 +13,11 @@ import { scrubCredentials, scrubObject } from '../services/scrub.js';
1313import { extractEntities , linkExtractedEntities } from '../services/entities.js' ;
1414import { validateMemoryInput , MAX_OBSERVED_BY } from '../middleware/validate.js' ;
1515import { dispatchNotification } from '../services/notifications.js' ;
16+ import { isKeywordSearchAvailable , indexMemory , deactivateMemory , keywordSearch } from '../services/keyword-search.js' ;
17+ import { isGraphSearchAvailable , graphSearch } from '../services/graph-search.js' ;
18+ import { reciprocalRankFusion } from '../services/rrf.js' ;
19+
20+ const MULTI_PATH_SEARCH = process . env . MULTI_PATH_SEARCH !== 'false' ; // default: true
1621import { getClientResolver } from '../services/client-resolver.js' ;
1722
1823export const memoryRouter = Router ( ) ;
@@ -127,6 +132,7 @@ memoryRouter.post('/', async (req, res) => {
127132 superseded_by : pointId ,
128133 superseded_at : now ,
129134 } ) ;
135+ deactivateMemory ( matches [ 0 ] . id ) . catch ( ( ) => { } ) ;
130136 dispatchNotification ( 'memory_superseded' , { id : matches [ 0 ] . id , ...matches [ 0 ] . payload } ) ;
131137 }
132138 } else if ( type === 'status' && req . body . subject ) {
@@ -139,6 +145,7 @@ memoryRouter.post('/', async (req, res) => {
139145 superseded_by : pointId ,
140146 superseded_at : now ,
141147 } ) ;
148+ deactivateMemory ( matches [ 0 ] . id ) . catch ( ( ) => { } ) ;
142149 dispatchNotification ( 'memory_superseded' , { id : matches [ 0 ] . id , ...matches [ 0 ] . payload } ) ;
143150 }
144151 }
@@ -183,6 +190,15 @@ memoryRouter.post('/', async (req, res) => {
183190 const vector = await embed ( cleanContent , 'store' ) ;
184191 await upsertPoint ( pointId , vector , payload ) ;
185192
193+ // Index in keyword search (fire-and-forget)
194+ if ( isKeywordSearchAvailable ( ) ) {
195+ indexMemory ( pointId , cleanContent , {
196+ client_id : client_id || 'global' ,
197+ source_agent,
198+ type,
199+ } ) . catch ( e => console . error ( '[memory:keyword-index]' , e . message ) ) ;
200+ }
201+
186202 // Dispatch webhook notification for new memory
187203 dispatchNotification ( 'memory_stored' , { id : pointId , ...payload } ) ;
188204
@@ -248,24 +264,24 @@ memoryRouter.post('/', async (req, res) => {
248264 }
249265} ) ;
250266
251- // GET /memory/search — Semantic search via Qdrant
267+ // GET /memory/search — Multi-path retrieval with RRF fusion
268+ // Paths: vector (semantic), keyword (BM25), graph (entity BFS)
252269memoryRouter . get ( '/search' , async ( req , res ) => {
253270 try {
254271 const { q, type, source_agent, client_id, category, limit, include_superseded, entity, format } = req . query ;
255272 const isCompact = format === 'compact' ;
273+ const isFull = format === 'full' ;
274+ const maxResults = Math . min ( parseInt ( limit ) || 10 , 100 ) ;
256275
257276 if ( ! q ) {
258277 return res . status ( 400 ) . json ( { error : 'Missing required query parameter: q' } ) ;
259278 }
260279
261- const vector = await embed ( q , 'search' ) ;
262-
263280 const filter = { } ;
264281 if ( type ) filter . type = type ;
265282 if ( source_agent ) filter . source_agent = source_agent ;
266283 if ( client_id ) filter . client_id = client_id ;
267284 if ( category ) filter . category = category ;
268- // By default, only return active memories (not superseded)
269285 if ( include_superseded !== 'true' ) filter . active = true ;
270286
271287 // Entity filter — resolve alias to canonical name, then filter via Qdrant payload
@@ -281,25 +297,99 @@ memoryRouter.get('/search', async (req, res) => {
281297 nestedFilters . push ( { arrayField : 'entities' , key : 'name' , value : entityName } ) ;
282298 }
283299
284- const rawResults = await searchPoints ( vector , filter , Math . min ( parseInt ( limit ) || 10 , 100 ) , nestedFilters ) ;
300+ // --- Multi-path retrieval ---
301+ const useMultiPath = MULTI_PATH_SEARCH && ! entity ; // entity filter is Qdrant-only
302+ const fetchLimit = useMultiPath ? Math . min ( maxResults * 2 , 50 ) : maxResults ;
303+
304+ // Always run vector search
305+ const vectorPromise = embed ( q , 'search' ) . then ( vector =>
306+ searchPoints ( vector , filter , fetchLimit , nestedFilters )
307+ ) ;
308+
309+ // Run keyword + graph in parallel (only if multi-path enabled)
310+ const keywordPromise = ( useMultiPath && isKeywordSearchAvailable ( ) )
311+ ? keywordSearch ( q , filter , fetchLimit ) . catch ( e => {
312+ console . error ( '[memory:keyword-search]' , e . message ) ;
313+ return [ ] ;
314+ } )
315+ : Promise . resolve ( [ ] ) ;
316+
317+ const graphPromise = ( useMultiPath && isGraphSearchAvailable ( ) )
318+ ? graphSearch ( q , filter , Math . min ( maxResults , 20 ) ) . catch ( e => {
319+ console . error ( '[memory:graph-search]' , e . message ) ;
320+ return [ ] ;
321+ } )
322+ : Promise . resolve ( [ ] ) ;
323+
324+ const [ vectorResults , keywordResults , graphResults ] = await Promise . all ( [
325+ vectorPromise , keywordPromise , graphPromise ,
326+ ] ) ;
327+
328+ // --- Build result set ---
329+ let finalResults ;
330+ const retrievalSources = { } ;
331+
332+ if ( useMultiPath && ( keywordResults . length > 0 || graphResults . length > 0 ) ) {
333+ // Build ranked lists for RRF
334+ const rankedLists = [
335+ vectorResults . map ( r => ( { id : r . id , source : 'vector' } ) ) ,
336+ ] ;
337+ if ( keywordResults . length > 0 ) {
338+ rankedLists . push ( keywordResults . map ( r => ( { id : r . memory_id , source : 'keyword' } ) ) ) ;
339+ }
340+ if ( graphResults . length > 0 ) {
341+ rankedLists . push ( graphResults . map ( r => ( { id : r . memory_id , source : 'graph' } ) ) ) ;
342+ }
343+
344+ const fused = reciprocalRankFusion ( rankedLists ) ;
345+ const topFused = fused . slice ( 0 , maxResults ) ;
346+
347+ // Track which sources contributed to each result
348+ for ( const f of topFused ) {
349+ retrievalSources [ f . id ] = f . sources ;
350+ }
351+
352+ // Build payload map from vector results (already have full payloads)
353+ const payloadMap = new Map ( ) ;
354+ for ( const r of vectorResults ) {
355+ payloadMap . set ( r . id , { id : r . id , score : r . score , payload : r . payload } ) ;
356+ }
357+
358+ // Fetch payloads for keyword/graph hits not in vector results
359+ const missingIds = topFused . map ( f => f . id ) . filter ( id => ! payloadMap . has ( id ) ) ;
360+ if ( missingIds . length > 0 ) {
361+ try {
362+ const fetched = await getPoints ( missingIds ) ;
363+ for ( const pt of fetched ) {
364+ payloadMap . set ( pt . id , { id : pt . id , score : 0 , payload : pt . payload } ) ;
365+ }
366+ } catch ( e ) {
367+ console . error ( '[memory:search] Batch fetch failed:' , e . message ) ;
368+ }
369+ }
370+
371+ // Assemble results in RRF order
372+ finalResults = topFused
373+ . map ( f => payloadMap . get ( f . id ) )
374+ . filter ( Boolean ) ;
375+ } else {
376+ // Single-path: vector only
377+ finalResults = vectorResults . slice ( 0 , maxResults ) ;
378+ }
285379
286380 // Apply confidence decay + access-weighted ranking
287- // Memories that get used more are more valuable — self-curating brain
288381 const COMPACT_MAX = 200 ;
289- const results = rawResults . map ( r => {
382+ const results = finalResults . map ( r => {
290383 const effectiveConfidence = computeEffectiveConfidence ( r . payload ) ;
291384 const p = r . payload ;
292-
293- // Access boost: log(access_count + 1) gives diminishing returns
294- // 0 accesses = 1.0x, 1 = 1.3x, 5 = 1.8x, 20 = 2.3x, 100 = 2.7x
295385 const accessBoost = 1 + ( 0.3 * Math . log2 ( ( p . access_count || 0 ) + 1 ) ) ;
296- const effectiveScore = + ( r . score * effectiveConfidence * accessBoost ) . toFixed ( 4 ) ;
386+ const effectiveScore = + ( ( ( r . score || 0.5 ) * effectiveConfidence * accessBoost ) ) . toFixed ( 4 ) ;
297387
298388 if ( isCompact ) {
299389 const text = p . text || '' ;
300390 return {
301391 id : r . id ,
302- score : + r . score . toFixed ( 4 ) ,
392+ score : + ( r . score || 0 ) . toFixed ( 4 ) ,
303393 effective_score : effectiveScore ,
304394 type : p . type ,
305395 content : text . length > COMPACT_MAX ? text . slice ( 0 , COMPACT_MAX ) + '...' : text ,
@@ -310,22 +400,28 @@ memoryRouter.get('/search', async (req, res) => {
310400 } ;
311401 }
312402
313- return {
403+ const base = {
314404 id : r . id ,
315- score : r . score ,
405+ score : r . score || 0 ,
316406 confidence : effectiveConfidence ,
317407 effective_score : effectiveScore ,
318408 ...p ,
319409 } ;
410+
411+ // In full format, show which retrieval paths contributed
412+ if ( isFull && retrievalSources [ r . id ] ) {
413+ base . retrieval_sources = retrievalSources [ r . id ] ;
414+ }
415+
416+ return base ;
320417 } ) ;
321418
322- // Re-sort by effective_score (now includes access weight)
419+ // Re-sort by effective_score
323420 results . sort ( ( a , b ) => b . effective_score - a . effective_score ) ;
324421
325422 // Async: increment access_count and update last_accessed_at for returned results
326423 const pointIds = results . map ( r => r . id ) ;
327424 if ( pointIds . length > 0 ) {
328- // Fire and forget — don't slow down the response
329425 Promise . resolve ( ) . then ( async ( ) => {
330426 try {
331427 const now = new Date ( ) . toISOString ( ) ;
@@ -341,11 +437,25 @@ memoryRouter.get('/search', async (req, res) => {
341437 } ) ;
342438 }
343439
344- res . json ( {
440+ const response = {
345441 query : q ,
346442 count : results . length ,
347443 results,
348- } ) ;
444+ } ;
445+
446+ // In full format, add retrieval metadata
447+ if ( isFull && useMultiPath ) {
448+ response . retrieval = {
449+ multi_path : true ,
450+ paths : {
451+ vector : vectorResults . length ,
452+ keyword : keywordResults . length ,
453+ graph : graphResults . length ,
454+ } ,
455+ } ;
456+ }
457+
458+ res . json ( response ) ;
349459 } catch ( err ) {
350460 console . error ( '[memory:search] Error:' , err . message ) ;
351461 res . status ( 500 ) . json ( { error : err . message } ) ;
@@ -426,6 +536,7 @@ memoryRouter.delete('/:id', async (req, res) => {
426536 deletion_reason : reason || null ,
427537 } ) ;
428538
539+ deactivateMemory ( id ) . catch ( ( ) => { } ) ;
429540 dispatchNotification ( 'memory_deleted' , { id, ...point . payload } ) ;
430541
431542 console . log ( `[memory:delete] Memory ${ id } soft-deleted by ${ req . authenticatedAgent || 'admin' } ${ reason ? ': ' + reason : '' } ` ) ;
0 commit comments