Skip to main content

lc_rag/
late_chunking.rs

1// lc-rag/src/late_chunking.rs
2//! Late chunking: embed the whole document token by token first, then pool
3//! along chunk boundaries (0.21.0 S5.2).
4//!
5//! Early (classic) chunking embeds each chunk independently, so cross-chunk
6//! references ("the company", "this plan") embed without their antecedent and
7//! fail to match queries phrased against the antecedent. Late chunking keeps
8//! the full document in one embedding pass (preserving cross-token attention)
9//! and only *pools* per chunk afterwards 鈥?the query flow is unchanged.
10//!
11//! Requires a token-level embedder ([`TokenLevelEmbeddings`], e.g.
12//! Qwen3-Embedding / BGE-M3 / Jina v3); pooled-only models cannot implement
13//! it. Best paired with large chunks (one document per pass).
14
15use lc_embeddings::token_level::{TokenEmbedding, TokenLevelEmbeddings};
16use lc_embeddings::EmbeddingError;
17
18/// Configuration for late chunking.
19#[derive(Debug, Clone)]
20pub struct LateChunkConfig {
21    /// Chunk size in bytes (chunks are byte windows over the document).
22    pub chunk_size: usize,
23    /// Overlap between consecutive chunks in bytes.
24    pub chunk_overlap: usize,
25}
26
27impl Default for LateChunkConfig {
28    fn default() -> Self {
29        Self {
30            chunk_size: 1024,
31            chunk_overlap: 128,
32        }
33    }
34}
35
36impl LateChunkConfig {
37    /// Creates a config with defaults.
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    /// Sets the chunk size in bytes.
43    pub fn with_chunk_size(mut self, chunk_size: usize) -> Self {
44        self.chunk_size = chunk_size;
45        self
46    }
47
48    /// Sets the chunk overlap in bytes.
49    pub fn with_chunk_overlap(mut self, chunk_overlap: usize) -> Self {
50        self.chunk_overlap = chunk_overlap;
51        self
52    }
53
54    /// Validates the config: `chunk_overlap < chunk_size` (otherwise chunks
55    /// would not advance).
56    pub fn validate(&self) -> Result<(), EmbeddingError> {
57        if self.chunk_size == 0 {
58            return Err(EmbeddingError::Config(
59                "late chunking: chunk_size must be > 0".to_string(),
60            ));
61        }
62        if self.chunk_overlap >= self.chunk_size {
63            return Err(EmbeddingError::Config(format!(
64                "late chunking: chunk_overlap ({}) must be < chunk_size ({})",
65                self.chunk_overlap, self.chunk_size
66            )));
67        }
68        Ok(())
69    }
70
71    /// Computes the chunk byte ranges `[start, end)` over `text`.
72    ///
73    /// Ranges are monotonically increasing, non-empty, cover the whole
74    /// document, and — 0.22.0 C6 fix — are always **snapped to UTF-8 char
75    /// boundaries** (the window advances in bytes for stable sizing, then
76    /// edges snap inward; slicing `text[start..end]` can no longer panic on
77    /// CJK / emoji text whose character straddles a window edge).
78    pub fn chunk_ranges(&self, text: &str) -> Vec<(usize, usize)> {
79        let text_len = text.len();
80        let mut ranges = Vec::new();
81        let step = self.chunk_size - self.chunk_overlap;
82        let mut start = 0usize;
83        while start < text_len {
84            let raw_end = (start + self.chunk_size).min(text_len);
85            // Snap the window edges inward to char boundaries.
86            let mut end = prev_char_boundary(text, raw_end);
87            if end <= start {
88                // Degenerate snap (e.g. start inside a wide char): push the
89                // end forward instead so the range is never empty.
90                end = next_char_boundary(text, raw_end).min(text_len);
91            }
92            if end <= start {
93                break;
94            }
95            ranges.push((start, end));
96            if end >= text_len {
97                break;
98            }
99            let next = next_char_boundary(text, (start + step).min(text_len));
100            if next <= start {
101                break;
102            }
103            start = next;
104        }
105        ranges
106    }
107}
108
109/// Next UTF-8 char boundary at or after `pos` (clamped to `text.len()`).
110fn next_char_boundary(text: &str, pos: usize) -> usize {
111    let mut p = pos.min(text.len());
112    while p < text.len() && !text.is_char_boundary(p) {
113        p += 1;
114    }
115    p
116}
117
118/// Previous UTF-8 char boundary at or before `pos`.
119fn prev_char_boundary(text: &str, pos: usize) -> usize {
120    let mut p = pos.min(text.len());
121    while p > 0 && !text.is_char_boundary(p) {
122        p -= 1;
123    }
124    p
125}
126
127/// One late chunk: a byte range of the original document plus its pooled,
128/// L2-normalized vector.
129#[derive(Debug, Clone, PartialEq)]
130pub struct LateChunk {
131    /// Byte range `[start, end)` of this chunk in the original document.
132    pub range: (usize, usize),
133    /// The chunk text (sliced from the original document).
134    pub text: String,
135    /// Mean-pooled, L2-normalized chunk vector.
136    pub vector: Vec<f32>,
137}
138
139/// Mean-pools the token vectors whose spans intersect `[range_start, range_end)`
140/// into a single L2-normalized vector.
141///
142/// Pure helper so the pooling math is unit-testable without an embedder.
143/// Tokens spanning a boundary contribute to both sides 鈥?that is the point of
144/// late chunking (context leaks across chunk borders by design). Returns
145/// `Err(EmptyInput)` when no token intersects (caller-side bug, not a valid
146/// chunk).
147pub fn pool_tokens(
148    tokens: &[TokenEmbedding],
149    range_start: usize,
150    range_end: usize,
151) -> Result<Vec<f32>, EmbeddingError> {
152    let dim = tokens.first().map(|t| t.vector.len()).unwrap_or(0);
153    let mut pooled = vec![0.0f32; dim];
154    let mut count = 0usize;
155    for token in tokens {
156        if token.span.intersects(range_start, range_end) {
157            for (p, v) in pooled.iter_mut().zip(token.vector.iter()) {
158                *p += v;
159            }
160            count += 1;
161        }
162    }
163    if count == 0 {
164        return Err(EmbeddingError::EmptyInput);
165    }
166    for p in &mut pooled {
167        *p /= count as f32;
168    }
169    lc_embeddings::l2_normalize(&mut pooled);
170    Ok(pooled)
171}
172
173/// Runs late chunking over a whole document.
174///
175/// 1. one token-level embedding pass over the full text (single model call);
176/// 2. slide a byte window ([`LateChunkConfig`]) over the token list;
177/// 3. pool each window into a chunk vector.
178///
179/// Generic over the embedder (static dispatch 鈥?the [`TokenLevelEmbeddings`]
180/// trait is RPITIT-based and not dyn-compatible by design; see its module docs).
181pub async fn late_chunk<E: TokenLevelEmbeddings>(
182    embedder: &E,
183    text: &str,
184    config: &LateChunkConfig,
185) -> Result<Vec<LateChunk>, EmbeddingError> {
186    config.validate()?;
187    let tokens = embedder.embed_tokens(text).await?;
188    let mut chunks = Vec::new();
189    for (start, end) in config.chunk_ranges(text) {
190        let vector = pool_tokens(&tokens, start, end)?;
191        chunks.push(LateChunk {
192            range: (start, end),
193            text: text[start..end].to_string(),
194            vector,
195        });
196    }
197    Ok(chunks)
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use lc_embeddings::token_level::{TokenEmbedding, TokenSpan};
204
205    /// Whitespace tokenizer over fixed-size vectors 鈥?same shape as the
206    /// `lc-embeddings` test mock; keeps pooling tests model-free.
207    fn token_embeddings(text: &str) -> Vec<TokenEmbedding> {
208        let mut out = Vec::new();
209        let mut cursor = 0usize;
210        for word in text.split_whitespace() {
211            let start = text[cursor..]
212                .find(word)
213                .map(|p| cursor + p)
214                .unwrap_or(cursor);
215            let end = start + word.len();
216            cursor = end;
217            out.push(TokenEmbedding {
218                span: TokenSpan::new(start, end),
219                vector: vec![word.bytes().map(|b| b as f32).sum::<f32>(), 1.0],
220            });
221        }
222        out
223    }
224
225    #[test]
226    fn config_validates_overlap() {
227        assert!(LateChunkConfig::new().validate().is_ok());
228        let bad = LateChunkConfig {
229            chunk_size: 10,
230            chunk_overlap: 10,
231        };
232        assert!(bad.validate().is_err());
233        let bad = LateChunkConfig {
234            chunk_size: 0,
235            chunk_overlap: 0,
236        };
237        assert!(bad.validate().is_err());
238    }
239
240    /// Sliding windows are monotonically increasing and cover the document.
241    #[test]
242    fn chunk_ranges_cover_document() {
243        let config = LateChunkConfig {
244            chunk_size: 10,
245            chunk_overlap: 2,
246        };
247        // ASCII-only document of 25 bytes: boundaries are identity.
248        let text_ascii = "x".repeat(25);
249        let ranges = config.chunk_ranges(&text_ascii);
250        // step 8: [0,10) [8,18) [16,25) — monotone, cover everything.
251        assert_eq!(ranges, vec![(0, 10), (8, 18), (16, 25)]);
252    }
253
254    #[test]
255    fn chunk_ranges_shorter_than_chunk_size() {
256        let config = LateChunkConfig {
257            chunk_size: 10,
258            chunk_overlap: 2,
259        };
260        assert_eq!(config.chunk_ranges("xxxxx"), vec![(0, 5)]);
261    }
262
263    /// C6 fix: a CJK window edge inside a multi-byte character snaps to a
264    /// char boundary — slicing `text[start..end]` never panics.
265    #[test]
266    fn chunk_ranges_snap_to_char_boundaries() {
267        let config = LateChunkConfig {
268            chunk_size: 10,
269            chunk_overlap: 2,
270        };
271        // 6 CJK chars = 18 bytes; step 8 would slice at byte 8 (mid-char).
272        let text = "你好世界天地".to_string(); // 3-byte chars, 18 bytes
273        let ranges = config.chunk_ranges(&text);
274        assert!(!ranges.is_empty());
275        for (start, end) in &ranges {
276            assert!(
277                text.is_char_boundary(*start),
278                "start {start} not a boundary"
279            );
280            assert!(text.is_char_boundary(*end), "end {end} not a boundary");
281            // Slicing is the regression: this would panic before the fix.
282            let _ = &text[*start..*end];
283        }
284        // Coverage: the last range reaches the end of the document.
285        assert_eq!(ranges.last().unwrap().1, 18);
286    }
287
288    /// Pooling averages intersecting tokens and L2-normalizes the result.
289    #[test]
290    fn pool_tokens_averages_and_normalizes() {
291        let tokens = token_embeddings("alpha beta");
292        // "alpha" = [98+108+112+104+97=519, 1], "beta" = [98+101+116+97=412, 1].
293        let pooled = pool_tokens(&tokens, 0, 10).unwrap();
294        let mean0: f32 = (519.0 + 412.0) / 2.0;
295        let norm = (mean0 * mean0 + 1.0).sqrt();
296        assert!((pooled[0] - mean0 / norm).abs() < 1e-5);
297        assert!((pooled[1] - 1.0 / norm).abs() < 1e-5);
298        let norm_sq: f32 = pooled.iter().map(|v| v * v).sum();
299        assert!((norm_sq - 1.0).abs() < 1e-5, "L2-normalized");
300    }
301
302    /// Tokens spanning a chunk boundary contribute to both chunks (late
303    /// chunking's context-leak property).
304    #[test]
305    fn pool_tokens_boundary_leaks() {
306        let tokens = token_embeddings("abcdef");
307        // Chunk A covers only "abc", chunk B covers only "def", but both meet
308        // at the "c"/"d" boundary; a token spanning [2, 5) intersects both.
309        let spanning = vec![TokenEmbedding {
310            span: TokenSpan::new(2, 5),
311            vector: vec![1.0, 0.0],
312        }];
313        assert!(pool_tokens(&spanning, 0, 3).is_ok());
314        assert!(pool_tokens(&spanning, 3, 6).is_ok());
315        let _ = tokens; // whitespace tokenizer has no spanning token; boundary check above suffices
316    }
317
318    /// Pooling a range with no intersecting token is an explicit error.
319    #[test]
320    fn pool_tokens_empty_range_errors() {
321        let tokens = token_embeddings("alpha");
322        let err = pool_tokens(&tokens, 100, 200).unwrap_err();
323        assert!(matches!(err, EmbeddingError::EmptyInput));
324    }
325
326    /// End-to-end with a generic (static-dispatch) embedder: one model pass,
327    /// per-chunk pooled vectors, text sliced from the original document.
328    #[tokio::test]
329    async fn late_chunk_end_to_end() {
330        struct MockTokenEmbeddings;
331
332        impl lc_embeddings::token_level::TokenLevelEmbeddings for MockTokenEmbeddings {
333            async fn embed_tokens(
334                &self,
335                text: &str,
336            ) -> Result<Vec<TokenEmbedding>, EmbeddingError> {
337                Ok(token_embeddings(text))
338            }
339        }
340
341        let text = "alpha beta gamma delta epsilon";
342        let config = LateChunkConfig {
343            chunk_size: 16,
344            chunk_overlap: 0,
345        };
346        let chunks = late_chunk(&MockTokenEmbeddings, text, &config)
347            .await
348            .unwrap();
349        assert_eq!(chunks.len(), 2);
350        assert_eq!(chunks[0].text, "alpha beta gamma");
351        assert_eq!(chunks[1].text, " delta epsilon");
352        // Each chunk vector is L2-normalized.
353        for chunk in &chunks {
354            let norm_sq: f32 = chunk.vector.iter().map(|v| v * v).sum();
355            assert!((norm_sq - 1.0).abs() < 1e-4);
356        }
357        // The second chunk is pooled from "delta"/"epsilon" only.
358        let delta_sum = b"delta".iter().map(|b| *b as f32).sum::<f32>();
359        let eps_sum: f32 = b"epsilon".iter().map(|b| *b as f32).sum();
360        let mean = (delta_sum + eps_sum) / 2.0;
361        let norm = (mean * mean + 1.0f32).sqrt();
362        assert!((chunks[1].vector[0] - mean / norm).abs() < 1e-4);
363    }
364
365    /// Invalid config fails fast before any model call.
366    #[tokio::test]
367    async fn late_chunk_rejects_invalid_config() {
368        struct MockTokenEmbeddings;
369
370        impl lc_embeddings::token_level::TokenLevelEmbeddings for MockTokenEmbeddings {
371            async fn embed_tokens(
372                &self,
373                _text: &str,
374            ) -> Result<Vec<TokenEmbedding>, EmbeddingError> {
375                Ok(Vec::new())
376            }
377        }
378        let config = LateChunkConfig {
379            chunk_size: 8,
380            chunk_overlap: 8,
381        };
382        let err = late_chunk(&MockTokenEmbeddings, "text", &config)
383            .await
384            .unwrap_err();
385        assert!(matches!(err, EmbeddingError::Config(_)));
386    }
387}