-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathchunker_test.go
More file actions
552 lines (483 loc) · 13.9 KB
/
Copy pathchunker_test.go
File metadata and controls
552 lines (483 loc) · 13.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
package minirag
import (
"reflect"
"strings"
"testing"
)
func TestNewTextChunker(t *testing.T) {
chunker := NewTextChunker(100, 20)
if chunker.MaxTokens != 100 {
t.Errorf("Expected MaxTokens to be 100, got %d", chunker.MaxTokens)
}
if chunker.Overlap != 20 {
t.Errorf("Expected Overlap to be 20, got %d", chunker.Overlap)
}
if chunker.TokenRegex == nil {
t.Error("Expected TokenRegex to be initialized")
}
}
func TestTextChunker_EstimateTokenCount(t *testing.T) {
chunker := NewTextChunker(100, 20)
tests := []struct {
name string
text string
expected int
}{
{
name: "empty string",
text: "",
expected: 0,
},
{
name: "single word",
text: "hello",
expected: 1,
},
{
name: "multiple words",
text: "hello world test",
expected: 3,
},
{
name: "with punctuation",
text: "Hello, world! How are you?",
expected: 5,
},
{
name: "with extra spaces",
text: " hello world ",
expected: 2,
},
{
name: "with newlines",
text: "hello\nworld\ntest",
expected: 3,
},
{
name: "complex text",
text: "This is a test. It has multiple sentences! And punctuation?",
expected: 10,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := chunker.EstimateTokenCount(tt.text)
if result != tt.expected {
t.Errorf("EstimateTokenCount(%q) = %d, want %d", tt.text, result, tt.expected)
}
})
}
}
func TestTextChunker_IsLongText(t *testing.T) {
chunker := NewTextChunker(5, 1)
tests := []struct {
name string
text string
expected bool
}{
{
name: "empty text",
text: "",
expected: false,
},
{
name: "short text",
text: "hello world",
expected: false,
},
{
name: "exact max tokens",
text: "one two three four five",
expected: false,
},
{
name: "over max tokens",
text: "one two three four five six",
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := chunker.IsLongText(tt.text)
if result != tt.expected {
t.Errorf("IsLongText(%q) = %v, want %v", tt.text, result, tt.expected)
}
})
}
}
func TestTextChunker_ChunkText_SingleChunk(t *testing.T) {
chunker := NewTextChunker(10, 2)
text := "This is a short text that fits in one chunk"
chunks := chunker.ChunkText(text)
if len(chunks) != 1 {
t.Errorf("Expected 1 chunk, got %d", len(chunks))
}
chunk := chunks[0]
if chunk.Text != text {
t.Errorf("Expected chunk text to be %q, got %q", text, chunk.Text)
}
if chunk.Index != 0 {
t.Errorf("Expected chunk index to be 0, got %d", chunk.Index)
}
if chunk.StartPos != 0 {
t.Errorf("Expected start position to be 0, got %d", chunk.StartPos)
}
if chunk.EndPos != len(text) {
t.Errorf("Expected end position to be %d, got %d", len(text), chunk.EndPos)
}
}
func TestTextChunker_ChunkText_MultipleChunks(t *testing.T) {
chunker := NewTextChunker(5, 1) // Very small chunks for testing
text := "This is the first sentence. This is the second sentence. This is the third sentence."
chunks := chunker.ChunkText(text)
if len(chunks) == 0 {
t.Fatal("Expected multiple chunks, got 0")
}
// Verify chunks have sequential indices
for i, chunk := range chunks {
if chunk.Index != i {
t.Errorf("Expected chunk %d to have index %d, got %d", i, i, chunk.Index)
}
if chunk.Text == "" {
t.Errorf("Chunk %d has empty text", i)
}
if chunk.TokenCount == 0 {
t.Errorf("Chunk %d has zero token count", i)
}
}
}
func TestTextChunker_ChunkText_WithOverlap(t *testing.T) {
chunker := NewTextChunker(3, 1)
text := "First sentence. Second sentence. Third sentence."
chunks := chunker.ChunkText(text)
if len(chunks) < 2 {
t.Errorf("Expected at least 2 chunks with overlap, got %d", len(chunks))
}
// Should have some overlap between consecutive chunks
// This is a basic check - the exact overlap depends on sentence structure
for i := 1; i < len(chunks); i++ {
if len(chunks[i].Text) == 0 {
t.Errorf("Chunk %d should not be empty", i)
}
}
}
func TestTextChunker_ChunkText_EmptyText(t *testing.T) {
chunker := NewTextChunker(100, 20)
tests := []string{"", " ", "\n\n", "\t\t"}
for _, text := range tests {
chunks := chunker.ChunkText(text)
if chunks != nil {
t.Errorf("Expected nil chunks for empty text %q, got %v", text, chunks)
}
}
}
func TestTextChunker_splitIntoSentences(t *testing.T) {
chunker := NewTextChunker(100, 20)
tests := []struct {
name string
text string
expected []string
}{
{
name: "single sentence",
text: "This is one sentence",
expected: []string{"This is one sentence"},
},
{
name: "multiple sentences",
text: "First sentence. Second sentence! Third sentence?",
expected: []string{"First sentence", "Second sentence", "Third sentence?"},
},
{
name: "with extra spaces",
text: "First sentence. Second sentence! Third sentence?",
expected: []string{"First sentence", "Second sentence", "Third sentence?"},
},
{
name: "no sentence boundaries",
text: "This is all one long sentence without proper punctuation",
expected: []string{"This is all one long sentence without proper punctuation"},
},
{
name: "paragraph splits",
text: "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
expected: []string{"First paragraph", "Second paragraph", "Third paragraph."},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := chunker.splitIntoSentences(tt.text)
if !reflect.DeepEqual(result, tt.expected) {
t.Errorf("splitIntoSentences(%q) = %v, want %v", tt.text, result, tt.expected)
}
})
}
}
func TestTextChunker_getOverlapText(t *testing.T) {
chunker := NewTextChunker(100, 20)
sentences := []string{"First sentence", "Second sentence", "Third sentence", "Fourth sentence"}
tests := []struct {
name string
currentIndex int
overlapTokens int
expected string
}{
{
name: "no overlap at start",
currentIndex: 0,
overlapTokens: 2,
expected: "",
},
{
name: "zero overlap tokens",
currentIndex: 2,
overlapTokens: 0,
expected: "",
},
{
name: "single sentence overlap",
currentIndex: 2,
overlapTokens: 2,
expected: "Second sentence",
},
{
name: "multiple sentence overlap",
currentIndex: 3,
overlapTokens: 4,
expected: "Second sentence Third sentence",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := chunker.getOverlapText(sentences, tt.currentIndex, tt.overlapTokens)
if result != tt.expected {
t.Errorf("getOverlapText() = %q, want %q", result, tt.expected)
}
})
}
}
func TestTextChunker_findStartPosition(t *testing.T) {
chunker := NewTextChunker(100, 20)
text := "This is a test sentence. This is another sentence."
tests := []struct {
name string
sentence string
expected int
}{
{
name: "sentence at start",
sentence: "This is a test sentence",
expected: 0,
},
{
name: "sentence in middle",
sentence: "This is another sentence",
expected: 25,
},
{
name: "sentence not found",
sentence: "Not in text",
expected: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := chunker.findStartPosition(text, tt.sentence)
if result != tt.expected {
t.Errorf("findStartPosition(%q, %q) = %d, want %d", text, tt.sentence, result, tt.expected)
}
})
}
}
func TestTextChunker_splitLongChunkByWords(t *testing.T) {
chunker := NewTextChunker(5, 1)
chunk := Chunk{
Text: "This is a very long chunk that needs to be split into smaller pieces",
Index: 0,
StartPos: 0,
EndPos: 100,
TokenCount: 14,
}
chunks := chunker.splitLongChunkByWords(chunk)
if len(chunks) == 0 {
t.Fatal("Expected at least one chunk")
}
// Verify each chunk respects max tokens
for i, c := range chunks {
if c.TokenCount > chunker.MaxTokens {
t.Errorf("Chunk %d has %d tokens, exceeds max %d", i, c.TokenCount, chunker.MaxTokens)
}
if c.Text == "" {
t.Errorf("Chunk %d has empty text", i)
}
}
// Verify the full text is preserved (approximately)
var allText strings.Builder
for i, c := range chunks {
if i > 0 {
allText.WriteString(" ")
}
allText.WriteString(c.Text)
}
// The reconstructed text should contain most of the original words
originalWords := strings.Fields(chunk.Text)
reconstructedWords := strings.Fields(allText.String())
if len(reconstructedWords) < len(originalWords)-2 { // Allow some variation due to overlap
t.Errorf("Lost too many words during splitting: original %d, reconstructed %d",
len(originalWords), len(reconstructedWords))
}
}
func TestTextChunker_splitLongChunkByWords_ShortChunk(t *testing.T) {
chunker := NewTextChunker(10, 2)
chunk := Chunk{
Text: "Short chunk",
Index: 0,
StartPos: 0,
EndPos: 11,
TokenCount: 2,
}
chunks := chunker.splitLongChunkByWords(chunk)
if len(chunks) != 1 {
t.Errorf("Expected 1 chunk for short text, got %d", len(chunks))
}
if chunks[0].Text != chunk.Text {
t.Errorf("Expected chunk text to remain unchanged: got %q, want %q", chunks[0].Text, chunk.Text)
}
}
func TestGetChunkID(t *testing.T) {
tests := []struct {
name string
documentID string
chunkIndex int
expected string
}{
{
name: "first chunk",
documentID: "doc1",
chunkIndex: 0,
expected: "doc1",
},
{
name: "second chunk",
documentID: "doc1",
chunkIndex: 1,
expected: "doc1_chunk_1",
},
{
name: "high index",
documentID: "test-doc",
chunkIndex: 15,
expected: "test-doc_chunk_15",
},
{
name: "complex doc id",
documentID: "user_123_document",
chunkIndex: 3,
expected: "user_123_document_chunk_3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := GetChunkID(tt.documentID, tt.chunkIndex)
if result != tt.expected {
t.Errorf("GetChunkID(%q, %d) = %q, want %q", tt.documentID, tt.chunkIndex, result, tt.expected)
}
})
}
}
func TestChunk_Struct(t *testing.T) {
// Test that Chunk struct works as expected
pageNum := 1
chunk := Chunk{
Text: "Test chunk",
Index: 0,
StartPos: 10,
EndPos: 20,
TokenCount: 2,
PageNumber: &pageNum,
ChunkType: "pdf_page",
}
if chunk.Text != "Test chunk" {
t.Errorf("Expected Text to be 'Test chunk', got %q", chunk.Text)
}
if chunk.PageNumber == nil || *chunk.PageNumber != 1 {
t.Errorf("Expected PageNumber to be 1, got %v", chunk.PageNumber)
}
if chunk.ChunkType != "pdf_page" {
t.Errorf("Expected ChunkType to be 'pdf_page', got %q", chunk.ChunkType)
}
// Test with nil page number
chunk2 := Chunk{
Text: "Test chunk 2",
PageNumber: nil,
ChunkType: "text",
}
if chunk2.PageNumber != nil {
t.Errorf("Expected PageNumber to be nil, got %v", chunk2.PageNumber)
}
}
// Benchmark tests for performance
func BenchmarkTextChunker_EstimateTokenCount(b *testing.B) {
chunker := NewTextChunker(1000, 200)
text := strings.Repeat("This is a test sentence. ", 100)
b.ResetTimer()
for i := 0; i < b.N; i++ {
chunker.EstimateTokenCount(text)
}
}
func BenchmarkTextChunker_ChunkText_Small(b *testing.B) {
chunker := NewTextChunker(100, 20)
text := strings.Repeat("This is a test sentence. ", 20)
b.ResetTimer()
for i := 0; i < b.N; i++ {
chunker.ChunkText(text)
}
}
func BenchmarkTextChunker_ChunkText_Large(b *testing.B) {
chunker := NewTextChunker(500, 100)
text := strings.Repeat("This is a test sentence with multiple words that will be chunked. ", 200)
b.ResetTimer()
for i := 0; i < b.N; i++ {
chunker.ChunkText(text)
}
}
// Integration test with realistic text
func TestTextChunker_RealWorldExample(t *testing.T) {
chunker := NewTextChunker(50, 10)
text := `
Artificial intelligence (AI) is intelligence demonstrated by machines, in contrast to the natural intelligence displayed by humans and animals.
Leading AI textbooks define the field as the study of "intelligent agents": any device that perceives its environment and takes actions that maximize its chance of successfully achieving its goals.
Colloquially, the term "artificial intelligence" is often used to describe machines that mimic "cognitive" functions that humans associate with the human mind, such as "learning" and "problem solving".
As machines become increasingly capable, tasks considered to require "intelligence" are often removed from the definition of AI, a phenomenon known as the AI effect.
A quip in Tesler's Theorem says "AI is whatever hasn't been done yet." For instance, optical character recognition is frequently excluded from things considered to be AI, having become a routine technology.
Modern machine learning techniques are a core part of AI. Machine learning algorithms build a model based on sample data, known as "training data", in order to make predictions or decisions without being explicitly programmed to do so.
`
chunks := chunker.ChunkText(text)
if len(chunks) == 0 {
t.Fatal("Expected at least one chunk")
}
// Verify basic properties
for i, chunk := range chunks {
if chunk.Text == "" {
t.Errorf("Chunk %d has empty text", i)
}
if chunk.Index != i {
t.Errorf("Chunk %d has wrong index %d", i, chunk.Index)
}
if chunk.TokenCount > chunker.MaxTokens {
t.Errorf("Chunk %d exceeds max tokens: %d > %d", i, chunk.TokenCount, chunker.MaxTokens)
}
}
// Verify that we didn't lose significant content
var allChunkText strings.Builder
for _, chunk := range chunks {
allChunkText.WriteString(chunk.Text)
allChunkText.WriteString(" ")
}
originalWords := strings.Fields(text)
chunkWords := strings.Fields(allChunkText.String())
// Should preserve most words (allowing for some duplication due to overlap)
if len(chunkWords) < len(originalWords) {
t.Errorf("Lost words during chunking: original %d, chunks %d", len(originalWords), len(chunkWords))
}
}