@@ -324,6 +324,18 @@ actor VecturaIndexService {
324324 }
325325 }
326326 }
327+ // Inject exact filename matches (by name only) to ensure visibility
328+ if !qExact. isEmpty {
329+ let qLower = qExact. lowercased ( )
330+ for entry in sidecar. files. values {
331+ let fname = URL ( fileURLWithPath: entry. path) . lastPathComponent. lowercased ( )
332+ if fname == qLower {
333+ let u = URL ( fileURLWithPath: entry. path)
334+ // Inject exact match with a strong base score (normalized)
335+ hits. append ( . init( id: name + " :exact: " + ( entry. id) , score: 1.0 , text: " " , fileURL: u, indexName: name) )
336+ }
337+ }
338+ }
327339 }
328340 // Dedup by fileURL to avoid repeats across indices; sort by score desc
329341 var seen = Set < String > ( )
@@ -333,17 +345,92 @@ actor VecturaIndexService {
333345 seen. insert ( k)
334346 return true
335347 }
336- // Rank exact filename matches first (case-insensitive), then keep score order within groups
337- let isExact : ( IndexSearchHit ) -> Bool = { h in
338- let name = h. fileURL. lastPathComponent
339- return !qExact. isEmpty && name. compare ( qExact, options: [ . caseInsensitive, . diacriticInsensitive] ) == . orderedSame
348+ // Rank by groups with fuzzy name similarity and embedding-first fallback:
349+ // 0 exact == query; 1 fuzzy>=0.90; 2 prefix same-ext; 3 embedding-general; 4 fuzzy>=0.75; 5 prefix other-ext; 6 contains; 7 others
350+ let nameOf : ( IndexSearchHit ) -> String = { $0. fileURL. lastPathComponent }
351+ let lowerQ = qExact. lowercased ( )
352+ let hasDot = lowerQ. contains ( " . " )
353+ let qExt = hasDot ? ( lowerQ. split ( separator: " . " ) . last. map ( String . init) ?? " " ) : " "
354+
355+ // Precompute name similarity
356+ var simMap : [ String : Double ] = [ : ]
357+ if !lowerQ. isEmpty {
358+ for h in deduped { simMap [ h. id] = nameSimilarity ( nameOf ( h) . lowercased ( ) , lowerQ) }
359+ }
360+
361+ func groupRank( _ h: IndexSearchHit ) -> Int {
362+ let n = nameOf ( h)
363+ if !qExact. isEmpty && n. compare ( qExact, options: [ . caseInsensitive, . diacriticInsensitive] ) == . orderedSame { return 0 }
364+ let sim = simMap [ h. id] ?? 0
365+ if sim >= 0.90 { return 1 }
366+ let nLower = n. lowercased ( )
367+ if !lowerQ. isEmpty && nLower. hasPrefix ( lowerQ) {
368+ if hasDot && h. fileURL. pathExtension. lowercased ( ) == qExt { return 2 }
369+ // different extension prefix match handled later at rank 5
370+ }
371+ // Embedding-general (none of the name heuristics hit yet)
372+ if sim < 0.75 && !( !lowerQ. isEmpty && nLower. contains ( lowerQ) ) { return 3 }
373+ if sim >= 0.75 { return 4 }
374+ if !lowerQ. isEmpty && nLower. hasPrefix ( lowerQ) { return 5 }
375+ if !lowerQ. isEmpty && nLower. contains ( lowerQ) { return 6 }
376+ return 7
377+ }
378+
379+ func clamp01( _ x: Double ) -> Double { min ( 1.0 , max ( 0.0 , x) ) }
380+
381+ func isExactName( _ h: IndexSearchHit ) -> Bool {
382+ guard !qExact. isEmpty else { return false }
383+ return nameOf ( h) . compare ( qExact, options: [ . caseInsensitive, . diacriticInsensitive] ) == . orderedSame
384+ }
385+
386+ func combinedScore( _ h: IndexSearchHit ) -> Double {
387+ let base = clamp01 ( h. score)
388+ let sim = simMap [ h. id] ?? 0.0
389+ let nLower = nameOf ( h) . lowercased ( )
390+ let sameExtBonus : Double = ( hasDot && h. fileURL. pathExtension. lowercased ( ) == qExt && nLower. hasPrefix ( lowerQ) ) ? 0.12 : 0.0
391+ let containsBonus : Double = ( !lowerQ. isEmpty && nLower. contains ( lowerQ) ) ? 0.03 : 0.0
392+ let exactBonus : Double = isExactName ( h) ? 0.5 : 0.0
393+ let highFuzzyBonus : Double = sim >= 0.90 ? ( 0.20 * ( sim - 0.90 ) / 0.10 ) : 0.0
394+ let medFuzzyBonus : Double = ( sim >= 0.75 && sim < 0.90 ) ? ( 0.10 * ( sim - 0.75 ) / 0.15 ) : 0.0
395+ let combined = base + exactBonus + sameExtBonus + max( highFuzzyBonus, medFuzzyBonus) + containsBonus
396+ return clamp01 ( combined)
397+ }
398+
399+ let ranked = deduped. sorted { a, b in
400+ // Exact always wins
401+ let ea = isExactName ( a) , eb = isExactName ( b)
402+ if ea != eb { return ea }
403+ let ca = combinedScore ( a)
404+ let cb = combinedScore ( b)
405+ if ca != cb { return ca > cb }
406+ // Stable tie-breaker: higher base score, then path lexicographically
407+ if a. score != b. score { return a. score > b. score }
408+ return nameOf ( a) < nameOf ( b)
340409 }
341- let exact = deduped. filter ( isExact)
342- let others = deduped. filter { !isExact( $0) }
343- let ranked = exact + others
344410 return Array ( ranked. prefix ( limit) )
345411 }
346412
413+ // Normalized name similarity in [0,1] via Levenshtein
414+ private func nameSimilarity( _ a: String , _ b: String ) -> Double {
415+ if a == b { return 1.0 }
416+ if a. isEmpty || b. isEmpty { return 0.0 }
417+ let la = Array ( a) ; let lb = Array ( b)
418+ let n = la. count; let m = lb. count
419+ var prev = Array ( 0 ... m)
420+ var cur = Array ( repeating: 0 , count: m + 1 )
421+ for i in 1 ... n {
422+ cur [ 0 ] = i
423+ for j in 1 ... m {
424+ let cost = ( la [ i- 1 ] == lb [ j- 1 ] ) ? 0 : 1
425+ cur [ j] = min ( prev [ j] + 1 , cur [ j- 1 ] + 1 , prev [ j- 1 ] + cost)
426+ }
427+ swap ( & prev, & cur)
428+ }
429+ let dist = prev [ m]
430+ let maxLen = max ( n, m)
431+ return max ( 0.0 , 1.0 - Double( dist) / Double( maxLen) )
432+ }
433+
347434 // MARK: - Paths
348435 private func baseDirectory( ) throws -> URL {
349436 let appSupport = try fm. url ( for: . applicationSupportDirectory, in: . userDomainMask, appropriateFor: nil , create: true )
0 commit comments