@@ -77,6 +77,16 @@ export interface HybridRetrieverOptions {
7777 * to the raw query without aborting retrieval.
7878 */
7979 hydeRetriever ?: HydeRetriever ;
80+ /**
81+ * Step-6: enable split-on-ambiguous rerank refinement. When set to a
82+ * value in (0, 1], the bottom fraction of traces by first-pass
83+ * rerank score are split at sentence boundaries, rescored with a
84+ * second rerank call (same query), and replaced by their better
85+ * half ONLY IF the better half outscores the original. Monotonic.
86+ *
87+ * Default: undefined (no split, Step 3 behavior preserved).
88+ */
89+ splitAmbiguousThreshold ?: number ;
8090 /** Default dense weight in RRF. @default 0.7 */
8191 defaultDenseWeight ?: number ;
8292 /** Default sparse weight in RRF. @default 0.3 */
@@ -121,6 +131,7 @@ export class HybridRetriever {
121131 private readonly memoryStore : MemoryStore ;
122132 private readonly rerankerService ?: RerankerService ;
123133 private readonly hydeRetriever ?: HydeRetriever ;
134+ private readonly splitAmbiguousThreshold ?: number ;
124135 private readonly defaultDenseWeight : number ;
125136 private readonly defaultSparseWeight : number ;
126137 private readonly defaultRrfK : number ;
@@ -130,6 +141,7 @@ export class HybridRetriever {
130141 this . bm25 = new BM25Index ( opts . bm25Config ) ;
131142 this . rerankerService = opts . rerankerService ;
132143 this . hydeRetriever = opts . hydeRetriever ;
144+ this . splitAmbiguousThreshold = opts . splitAmbiguousThreshold ;
133145 this . defaultDenseWeight = opts . defaultDenseWeight ?? 0.7 ;
134146 this . defaultSparseWeight = opts . defaultSparseWeight ?? 0.3 ;
135147 this . defaultRrfK = opts . defaultRrfK ?? 60 ;
@@ -214,6 +226,7 @@ export class HybridRetriever {
214226 }
215227
216228 // Optional rerank: same 0.7 cognitive + 0.3 neural blend as baseline.
229+ let splitDiagnostic : { threshold : number ; candidateCount : number ; replacedIds : string [ ] } | undefined ;
217230 if ( this . rerankerService && hydrated . length > 0 ) {
218231 try {
219232 const rerankerOutput = await this . rerankerService . rerank (
@@ -236,6 +249,21 @@ export class HybridRetriever {
236249 trace . retrievalScore = 0.7 * trace . retrievalScore + 0.3 * neural ;
237250 }
238251 }
252+
253+ // Step-6: split-on-ambiguous refinement.
254+ if (
255+ this . splitAmbiguousThreshold !== undefined &&
256+ this . splitAmbiguousThreshold > 0 &&
257+ hydrated . length > 0
258+ ) {
259+ splitDiagnostic = await this . refineAmbiguous (
260+ hydrated ,
261+ neuralScores ,
262+ query ,
263+ this . splitAmbiguousThreshold ,
264+ ) ;
265+ }
266+
239267 hydrated . sort ( ( a , b ) => b . retrievalScore - a . retrievalScore ) ;
240268 } catch {
241269 // Reranker errors are non-critical: keep RRF ordering.
@@ -250,6 +278,7 @@ export class HybridRetriever {
250278 scoringMs : denseTimings . scoringMs ,
251279 totalMs : Date . now ( ) - startTime ,
252280 hypothesis : hypothesisDiagnostic ,
281+ splitOnAmbiguous : splitDiagnostic ,
253282 } ) ;
254283 }
255284
@@ -263,6 +292,7 @@ export class HybridRetriever {
263292 scoringMs : number ;
264293 totalMs : number ;
265294 hypothesis ?: string ;
295+ splitOnAmbiguous ?: { threshold : number ; candidateCount : number ; replacedIds : string [ ] } ;
266296 } ,
267297 ) : CognitiveRetrievalResult {
268298 return {
@@ -275,7 +305,111 @@ export class HybridRetriever {
275305 totalTimeMs : d . totalMs ,
276306 ...( d . escalations ? { escalations : d . escalations } : { } ) ,
277307 ...( d . hypothesis ? { hyde : { hypothesis : d . hypothesis } } : { } ) ,
308+ ...( d . splitOnAmbiguous ? { splitOnAmbiguous : d . splitOnAmbiguous } : { } ) ,
278309 } ,
279310 } ;
280311 }
312+
313+ /**
314+ * Step-6: split bottom-fraction traces by neural score, rescore the
315+ * halves, replace a trace's content with its better half IFF the
316+ * better half's neural score outranks the original's. Monotonic.
317+ *
318+ * Modifies `hydrated` in place: `trace.content` and `trace.retrievalScore`
319+ * are updated for replaced traces. Returns a diagnostic summary.
320+ */
321+ private async refineAmbiguous (
322+ hydrated : ScoredMemoryTrace [ ] ,
323+ neuralScores : Map < string , number > ,
324+ query : string ,
325+ threshold : number ,
326+ ) : Promise < { threshold : number ; candidateCount : number ; replacedIds : string [ ] } > {
327+ const replacedIds : string [ ] = [ ] ;
328+
329+ const sortedByNeural = hydrated
330+ . map ( ( t ) => ( { trace : t , neural : neuralScores . get ( t . id ) ?? 0 } ) )
331+ . sort ( ( a , b ) => a . neural - b . neural ) ;
332+ const candidateCount = Math . ceil ( hydrated . length * threshold ) ;
333+ const candidates = sortedByNeural . slice ( 0 , candidateCount ) ;
334+
335+ type Split = { traceId : string ; halfAId : string ; halfBId : string ; halfA : string ; halfB : string ; originalNeural : number } ;
336+ const splits : Split [ ] = [ ] ;
337+ for ( const { trace, neural } of candidates ) {
338+ const halves = this . splitAtMidpointSentence ( trace . content ) ;
339+ if ( ! halves ) continue ;
340+ splits . push ( {
341+ traceId : trace . id ,
342+ halfAId : `${ trace . id } ::a` ,
343+ halfBId : `${ trace . id } ::b` ,
344+ halfA : halves [ 0 ] ,
345+ halfB : halves [ 1 ] ,
346+ originalNeural : neural ,
347+ } ) ;
348+ }
349+
350+ if ( splits . length === 0 ) {
351+ return { threshold, candidateCount, replacedIds } ;
352+ }
353+
354+ const halfDocs = splits . flatMap ( ( s ) => [
355+ { id : s . halfAId , content : s . halfA } ,
356+ { id : s . halfBId , content : s . halfB } ,
357+ ] ) ;
358+ let halfScores : Map < string , number > ;
359+ try {
360+ const halfOut = await this . rerankerService ! . rerank (
361+ { query, documents : halfDocs } ,
362+ { topN : halfDocs . length } ,
363+ ) ;
364+ halfScores = new Map ( halfOut . results . map ( ( r ) => [ r . id , r . relevanceScore ] ) ) ;
365+ } catch {
366+ return { threshold, candidateCount, replacedIds } ;
367+ }
368+
369+ const traceById = new Map ( hydrated . map ( ( t ) => [ t . id , t ] ) ) ;
370+ for ( const s of splits ) {
371+ const a = halfScores . get ( s . halfAId ) ?? - Infinity ;
372+ const b = halfScores . get ( s . halfBId ) ?? - Infinity ;
373+ const winningScore = Math . max ( a , b ) ;
374+ if ( winningScore <= s . originalNeural ) continue ;
375+ const winningText = a >= b ? s . halfA : s . halfB ;
376+ const trace = traceById . get ( s . traceId ) ;
377+ if ( ! trace ) continue ;
378+ trace . content = winningText ;
379+ trace . retrievalScore += 0.3 * ( winningScore - s . originalNeural ) ;
380+ replacedIds . push ( s . traceId ) ;
381+ }
382+
383+ return { threshold, candidateCount, replacedIds } ;
384+ }
385+
386+ /**
387+ * Split a string at the sentence boundary nearest its midpoint.
388+ * Returns [firstHalf, secondHalf] or null if the string is too short
389+ * or no valid boundary is found.
390+ */
391+ private splitAtMidpointSentence ( text : string ) : [ string , string ] | null {
392+ if ( text . length < 50 ) return null ;
393+ const mid = Math . floor ( text . length / 2 ) ;
394+ const window = Math . floor ( text . length * 0.4 ) ;
395+ const lo = Math . max ( 0 , mid - window ) ;
396+ const hi = Math . min ( text . length , mid + window ) ;
397+ for ( let offset = 0 ; offset <= window ; offset ++ ) {
398+ for ( const sign of [ - 1 , 1 ] as const ) {
399+ const i = mid + sign * offset ;
400+ if ( i < lo || i > hi ) continue ;
401+ if (
402+ i > 0 &&
403+ i < text . length - 1 &&
404+ / [ . ! ? ] / . test ( text [ i ] ) &&
405+ / \s / . test ( text [ i + 1 ] )
406+ ) {
407+ return [ text . slice ( 0 , i + 1 ) . trim ( ) , text . slice ( i + 1 ) . trim ( ) ] ;
408+ }
409+ }
410+ }
411+ const spaceIdx = text . indexOf ( ' ' , mid ) ;
412+ if ( spaceIdx === - 1 || spaceIdx >= text . length - 1 ) return null ;
413+ return [ text . slice ( 0 , spaceIdx ) . trim ( ) , text . slice ( spaceIdx + 1 ) . trim ( ) ] ;
414+ }
281415}
0 commit comments