@@ -49,6 +49,13 @@ export function bm25RankToScore(rank: number): number {
4949 return 1 / ( 1 + rank ) ;
5050}
5151
52+ /**
53+ * Check if two line ranges overlap.
54+ */
55+ function linesOverlap ( aStart : number , aEnd : number , bStart : number , bEnd : number ) : boolean {
56+ return aStart <= bEnd && bStart <= aEnd ;
57+ }
58+
5259export async function mergeHybridResults ( params : {
5360 vector : HybridVectorResult [ ] ;
5461 keyword : HybridKeywordResult [ ] ;
@@ -87,6 +94,9 @@ export async function mergeHybridResults(params: {
8794 }
8895 > ( ) ;
8996
97+ // Secondary index: group vector results by path for fallback matching
98+ const vectorByPath = new Map < string , HybridVectorResult [ ] > ( ) ;
99+
90100 for ( const r of params . vector ) {
91101 byId . set ( r . id , {
92102 id : r . id ,
@@ -98,26 +108,64 @@ export async function mergeHybridResults(params: {
98108 vectorScore : r . vectorScore ,
99109 textScore : 0 ,
100110 } ) ;
111+ const existing = vectorByPath . get ( r . path ) ;
112+ if ( existing ) {
113+ existing . push ( r ) ;
114+ } else {
115+ vectorByPath . set ( r . path , [ r ] ) ;
116+ }
101117 }
102118
103119 for ( const r of params . keyword ) {
104120 const existing = byId . get ( r . id ) ;
105121 if ( existing ) {
122+ // Exact chunk ID match — update textScore directly
106123 existing . textScore = r . textScore ;
107124 if ( r . snippet && r . snippet . length > 0 ) {
108125 existing . snippet = r . snippet ;
109126 }
110127 } else {
111- byId . set ( r . id , {
112- id : r . id ,
113- path : r . path ,
114- startLine : r . startLine ,
115- endLine : r . endLine ,
116- source : r . source ,
117- snippet : r . snippet ,
118- vectorScore : 0 ,
119- textScore : r . textScore ,
120- } ) ;
128+ // No chunk ID match — try path + line range overlap as fallback
129+ // Pick the overlapping vector chunk with the highest vectorScore
130+ const candidates = vectorByPath . get ( r . path ) ;
131+ let merged = false ;
132+ if ( candidates ) {
133+ let best : HybridVectorResult | undefined ;
134+ for ( const v of candidates ) {
135+ if ( linesOverlap ( r . startLine , r . endLine , v . startLine , v . endLine ) ) {
136+ if ( ! best || v . vectorScore > best . vectorScore ) {
137+ best = v ;
138+ }
139+ }
140+ }
141+ if ( best ) {
142+ const entry = byId . get ( best . id ) ! ;
143+ entry . textScore = Math . max ( entry . textScore , r . textScore ) ;
144+ if ( r . startLine < entry . startLine ) {
145+ entry . startLine = r . startLine ;
146+ }
147+ if ( r . endLine > entry . endLine ) {
148+ entry . endLine = r . endLine ;
149+ }
150+ if ( r . snippet && r . snippet . length > 0 ) {
151+ entry . snippet = r . snippet ;
152+ }
153+ merged = true ;
154+ }
155+ }
156+ if ( ! merged ) {
157+ // No overlap found — add as keyword-only entry
158+ byId . set ( r . id , {
159+ id : r . id ,
160+ path : r . path ,
161+ startLine : r . startLine ,
162+ endLine : r . endLine ,
163+ source : r . source ,
164+ snippet : r . snippet ,
165+ vectorScore : 0 ,
166+ textScore : r . textScore ,
167+ } ) ;
168+ }
121169 }
122170 }
123171
0 commit comments