@@ -1033,6 +1033,14 @@ func (db *DB) hybridVectorLeg(
10331033 return leg , nil
10341034}
10351035
1036+ // maxHybridFTSBatches caps how many k-row FTS batches hybridFTSLeg fetches.
1037+ // It bounds the worst-case work when discard dominates — many rows collapsing
1038+ // into one unit, or a narrow f.Scope dropping most rows — while letting the
1039+ // leg keep paging past discarded rows instead of under-filling after the
1040+ // first batch. The residual is documented: a leg needing survivors deeper
1041+ // than maxHybridFTSBatches x k rows can still under-fill.
1042+ const maxHybridFTSBatches = 4
1043+
10361044// hybridFTSLeg runs a rank-ordered FTS query over the embedded universe
10371045// (role user/assistant, is_system = 0, system-prefix excluded -- the same
10381046// predicate ScanEmbeddableUnits uses), scoped in SQL to sessions passing
@@ -1045,9 +1053,38 @@ func (db *DB) hybridVectorLeg(
10451053// hits in one unit collapse to the best-ranked one. A hit with no containing
10461054// unit keeps a message-granularity key, is never subordinate-penalized, and
10471055// so survives fusion on its own.
1056+ //
1057+ // Rows are fetched in rank-ordered batches of k with OFFSET continuation:
1058+ // collapse and scope filtering can discard most of a batch, so the leg keeps
1059+ // fetching until it holds k entries, the stream is exhausted, or
1060+ // maxHybridFTSBatches is hit. The display seen-check dedups across batches;
1061+ // earlier batches rank better, so the best-ranked hit per unit always wins.
10481062func (db * DB ) hybridFTSLeg (
10491063 ctx context.Context , f ContentSearchFilter , searcher VectorSearcher , k int ,
10501064) (hybridLeg , error ) {
1065+ leg := hybridLeg {display : make (map [string ]hybridDisplay , k )}
1066+ for batch := range maxHybridFTSBatches {
1067+ hits , err := db .fetchHybridFTSBatch (ctx , f , k , batch * k )
1068+ if err != nil {
1069+ return hybridLeg {}, err
1070+ }
1071+ if err := appendHybridFTSHits (ctx , searcher , f .Scope , hits , & leg ); err != nil {
1072+ return hybridLeg {}, err
1073+ }
1074+ if len (hits ) < k || len (leg .ranked ) >= k {
1075+ break
1076+ }
1077+ }
1078+ return leg , nil
1079+ }
1080+
1081+ // fetchHybridFTSBatch fetches one rank-ordered batch of at most k FTS message
1082+ // rows for hybridFTSLeg, starting at offset. The ORDER BY carries m.id as a
1083+ // deterministic tiebreak so OFFSET continuation is stable across batches when
1084+ // ranks tie.
1085+ func (db * DB ) fetchHybridFTSBatch (
1086+ ctx context.Context , f ContentSearchFilter , k , offset int ,
1087+ ) ([]hybridDisplay , error ) {
10511088 scope , scopeArgs := semanticSessionScopeSubquery (f )
10521089 query := fmt .Sprintf (`
10531090 SELECT m.session_id, m.ordinal,
@@ -1056,45 +1093,57 @@ func (db *DB) hybridFTSLeg(
10561093 WHERE messages_fts MATCH ? AND m.role IN ('user','assistant')
10571094 AND m.is_system = 0 AND %s
10581095 AND m.%s
1059- ORDER BY f.rank LIMIT ?` ,
1096+ ORDER BY f.rank, m.id LIMIT ? OFFSET ?` ,
10601097 SystemPrefixSQL ("m.content" , "m.role" ), scope )
10611098
10621099 args := []any {PrepareFTSQuery (f .Pattern )}
10631100 args = append (args , scopeArgs ... )
1064- args = append (args , k )
1101+ args = append (args , k , offset )
10651102
10661103 rows , err := db .getReader ().QueryContext (ctx , query , args ... )
10671104 if err != nil {
1068- return hybridLeg {} , classifyFTSError (fmt .Errorf ("hybrid search fts leg: %w" , err ))
1105+ return nil , classifyFTSError (fmt .Errorf ("hybrid search fts leg: %w" , err ))
10691106 }
10701107 defer rows .Close ()
10711108
10721109 var hits []hybridDisplay
10731110 for rows .Next () {
10741111 var hit hybridDisplay
10751112 if err := rows .Scan (& hit .sessionID , & hit .ordinal , & hit .snippet ); err != nil {
1076- return hybridLeg {} , fmt .Errorf ("scan hybrid fts hit: %w" , err )
1113+ return nil , fmt .Errorf ("scan hybrid fts hit: %w" , err )
10771114 }
10781115 hits = append (hits , hit )
10791116 }
10801117 if err := rows .Err (); err != nil {
1081- return hybridLeg {} , err
1118+ return nil , err
10821119 }
1120+ return hits , nil
1121+ }
10831122
1123+ // appendHybridFTSHits resolves one batch of FTS message hits to their
1124+ // containing units and accumulates the survivors into leg: hits outside
1125+ // scope are dropped, and a unit already seen (within or across batches)
1126+ // keeps its earlier, better-ranked entry.
1127+ func appendHybridFTSHits (
1128+ ctx context.Context , searcher VectorSearcher , scope string ,
1129+ hits []hybridDisplay , leg * hybridLeg ,
1130+ ) error {
1131+ if len (hits ) == 0 {
1132+ return nil
1133+ }
10841134 refs := make ([]MessageRef , len (hits ))
10851135 for i , hit := range hits {
10861136 refs [i ] = MessageRef {SessionID : hit .sessionID , Ordinal : hit .ordinal }
10871137 }
10881138 units , err := searcher .ResolveMessageUnits (ctx , refs )
10891139 if err != nil {
1090- return hybridLeg {}, fmt .Errorf ("resolving fts hits to units: %w" , err )
1140+ return fmt .Errorf ("resolving fts hits to units: %w" , err )
10911141 }
10921142 if len (units ) != len (refs ) {
1093- return hybridLeg {}, fmt .Errorf (
1143+ return fmt .Errorf (
10941144 "resolving fts hits to units: got %d units for %d refs" , len (units ), len (refs ))
10951145 }
10961146
1097- leg := hybridLeg {display : make (map [string ]hybridDisplay , len (hits ))}
10981147 for i , hit := range hits {
10991148 key := messageFusionKey (hit .sessionID , hit .ordinal )
11001149 hit .ordinalStart , hit .ordinalEnd = hit .ordinal , hit .ordinal
@@ -1104,7 +1153,7 @@ func (db *DB) hybridFTSLeg(
11041153 hit .ordinalEnd = units [i ].OrdinalEnd
11051154 hit .subordinate = units [i ].Subordinate
11061155 }
1107- if scopeExcludes (f . Scope , hit .subordinate ) {
1156+ if scopeExcludes (scope , hit .subordinate ) {
11081157 continue
11091158 }
11101159 if _ , seen := leg .display [key ]; seen {
@@ -1113,7 +1162,7 @@ func (db *DB) hybridFTSLeg(
11131162 leg .ranked = append (leg .ranked , unitRanked {Key : key , Subordinate : hit .subordinate })
11141163 leg .display [key ] = hit
11151164 }
1116- return leg , nil
1165+ return nil
11171166}
11181167
11191168// enrichHybridMatches looks up session/message metadata for the fused units
@@ -1325,7 +1374,8 @@ func (db *DB) enrichSemanticHits(
13251374// approximate snippet text semantic/hybrid modes locate within a message's
13261375// full content: the vector index's trailing unicode ellipsis
13271376// (internal/vector's truncateRunes) and FTS5 snippet()'s literal "..." marker
1328- // (used at both ends), configured as hybridFTSLeg's 5th snippet() argument.
1377+ // (used at both ends), configured as fetchHybridFTSBatch's 5th snippet()
1378+ // argument.
13291379var snippetTruncationMarkers = []string {"..." , "…" }
13301380
13311381// approxSnippetSpan locates approx (a searcher-provided chunk/snippet or
0 commit comments