Files
simstudioai--sim/apps/sim/lib/chunkers/regex-chunker.test.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

392 lines
14 KiB
TypeScript

/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { RegexChunker } from './regex-chunker'
describe('RegexChunker', () => {
describe('empty and whitespace input', () => {
it.concurrent('should return empty array for empty string', async () => {
const chunker = new RegexChunker({ pattern: '\\n\\n' })
const chunks = await chunker.chunk('')
expect(chunks).toEqual([])
})
it.concurrent('should return empty array for whitespace-only input', async () => {
const chunker = new RegexChunker({ pattern: '\\n\\n' })
const chunks = await chunker.chunk(' \n\n ')
expect(chunks).toEqual([])
})
})
describe('small content', () => {
it.concurrent('should return single chunk when content fits in chunkSize', async () => {
const chunker = new RegexChunker({ pattern: '\\n\\n', chunkSize: 100 })
const text = 'This is a short text.'
const chunks = await chunker.chunk(text)
expect(chunks).toHaveLength(1)
expect(chunks[0].text).toBe(text)
})
})
describe('basic regex splitting', () => {
it.concurrent('should split on double newlines with pattern \\n\\n', async () => {
const chunker = new RegexChunker({ pattern: '\\n\\n', chunkSize: 20 })
const text =
'First paragraph content here.\n\nSecond paragraph content here.\n\nThird paragraph content here.'
const chunks = await chunker.chunk(text)
expect(chunks.length).toBeGreaterThan(1)
})
})
describe('custom pattern splitting', () => {
it.concurrent('should split text at --- delimiters', async () => {
const chunker = new RegexChunker({ pattern: '---', chunkSize: 20 })
const text =
'Section one has enough content to fill a chunk on its own here.---Section two also has enough content to fill another chunk here.---Section three needs content too for splitting.'
const chunks = await chunker.chunk(text)
expect(chunks.length).toBeGreaterThan(1)
})
})
describe('segment merging', () => {
it.concurrent('should merge small adjacent segments up to chunkSize', async () => {
const chunker = new RegexChunker({ pattern: '\\n\\n', chunkSize: 100 })
const text = 'Short.\n\nAlso short.\n\nTiny.\n\nSmall too.'
const chunks = await chunker.chunk(text)
expect(chunks).toHaveLength(1)
expect(chunks[0].text).toContain('Short.')
expect(chunks[0].text).toContain('Also short.')
})
})
describe('oversized segment fallback', () => {
it.concurrent(
'should sub-chunk segments larger than chunkSize via word boundaries',
async () => {
const chunker = new RegexChunker({ pattern: '---', chunkSize: 10 })
const longSegment =
'This is a very long segment with many words that exceeds the chunk size limit significantly. '
const text = `${longSegment}---${longSegment}`
const chunks = await chunker.chunk(text)
expect(chunks.length).toBeGreaterThan(2)
}
)
})
describe('no-match fallback', () => {
it.concurrent(
'should fall back to word-boundary splitting when regex matches nothing',
async () => {
const chunker = new RegexChunker({ pattern: '###SPLIT###', chunkSize: 10 })
const text = 'This is a text with no matching delimiter anywhere in the content at all.'
const chunks = await chunker.chunk(text)
expect(chunks.length).toBeGreaterThan(1)
}
)
})
describe('chunk size respected', () => {
it.concurrent('should not exceed chunkSize tokens approximately', async () => {
const chunkSize = 30
const chunker = new RegexChunker({ pattern: '\\n\\n', chunkSize })
const text =
'Paragraph one with some words. '.repeat(5) +
'\n\n' +
'Paragraph two with more words. '.repeat(5) +
'\n\n' +
'Paragraph three continues here. '.repeat(5)
const chunks = await chunker.chunk(text)
for (const chunk of chunks) {
expect(chunk.tokenCount).toBeLessThanOrEqual(chunkSize + 10)
}
})
})
describe('overlap', () => {
it.concurrent('should share content between chunks when chunkOverlap > 0', async () => {
const chunker = new RegexChunker({ pattern: '\\n\\n', chunkSize: 20, chunkOverlap: 5 })
const text =
'First paragraph with enough content.\n\nSecond paragraph with more content.\n\nThird paragraph with even more.'
const chunks = await chunker.chunk(text)
if (chunks.length > 1) {
const firstChunkEnd = chunks[0].text.slice(-10)
const secondChunkStart = chunks[1].text.slice(0, 20)
expect(secondChunkStart.length).toBeGreaterThan(0)
expect(chunks[1].text.length).toBeGreaterThan(0)
}
})
})
describe('chunk metadata', () => {
it.concurrent('should include text, tokenCount, and metadata with indices', async () => {
const chunker = new RegexChunker({ pattern: '\\n\\n', chunkSize: 100 })
const text = 'Hello world test content.'
const chunks = await chunker.chunk(text)
expect(chunks).toHaveLength(1)
expect(chunks[0].text).toBe(text)
expect(chunks[0].tokenCount).toBe(Math.ceil(text.length / 4))
expect(chunks[0].metadata.startIndex).toBeDefined()
expect(chunks[0].metadata.endIndex).toBeDefined()
expect(chunks[0].metadata.startIndex).toBe(0)
})
it.concurrent('should have non-negative indices across multiple chunks', async () => {
const chunker = new RegexChunker({ pattern: '\\n\\n', chunkSize: 20, chunkOverlap: 0 })
const text = 'First paragraph here.\n\nSecond paragraph here.\n\nThird paragraph here.'
const chunks = await chunker.chunk(text)
for (const chunk of chunks) {
expect(chunk.metadata.startIndex).toBeGreaterThanOrEqual(0)
expect(chunk.metadata.endIndex).toBeGreaterThanOrEqual(chunk.metadata.startIndex)
}
})
})
describe('invalid regex', () => {
it.concurrent('should throw error for invalid regex pattern', async () => {
expect(() => new RegexChunker({ pattern: '[invalid' })).toThrow()
})
})
describe('empty pattern', () => {
it.concurrent('should throw error for empty pattern', async () => {
expect(() => new RegexChunker({ pattern: '' })).toThrow('Regex pattern is required')
})
})
describe('pattern too long', () => {
it.concurrent('should throw error for pattern exceeding 500 characters', async () => {
const longPattern = 'a'.repeat(501)
expect(() => new RegexChunker({ pattern: longPattern })).toThrow(
'Regex pattern exceeds maximum length of 500 characters'
)
})
})
describe('ReDoS protection', () => {
it.concurrent('should accept safe pattern \\n+', async () => {
expect(() => new RegexChunker({ pattern: '\\n+' })).not.toThrow()
})
it.concurrent('should accept safe pattern [,;]', async () => {
expect(() => new RegexChunker({ pattern: '[,;]' })).not.toThrow()
})
})
describe('capturing groups', () => {
it.concurrent(
'should not include delimiter text as a chunk when pattern has capturing groups',
async () => {
const chunker = new RegexChunker({
pattern: '(---)',
chunkSize: 1024,
strictBoundaries: true,
})
const text = 'Section one content.---Section two content.---Section three content.'
const chunks = await chunker.chunk(text)
expect(chunks).toHaveLength(3)
expect(chunks[0].text).toBe('Section one content.')
expect(chunks[1].text).toBe('Section two content.')
expect(chunks[2].text).toBe('Section three content.')
for (const chunk of chunks) {
expect(chunk.text).not.toBe('---')
}
}
)
it.concurrent(
'should not include delimiter text when pattern uses named capture groups',
async () => {
const chunker = new RegexChunker({
pattern: '(?<sep>---)',
chunkSize: 1024,
strictBoundaries: true,
})
const text = 'Section one content.---Section two content.---Section three content.'
const chunks = await chunker.chunk(text)
expect(chunks).toHaveLength(3)
expect(chunks[0].text).toBe('Section one content.')
expect(chunks[1].text).toBe('Section two content.')
expect(chunks[2].text).toBe('Section three content.')
for (const chunk of chunks) {
expect(chunk.text).not.toBe('---')
}
}
)
it.concurrent('should preserve lookbehind whose body contains a > character', async () => {
const chunker = new RegexChunker({
pattern: '(?<=</section>)',
chunkSize: 1024,
strictBoundaries: true,
})
const text = '<section>one</section><section>two</section><section>three</section>'
const chunks = await chunker.chunk(text)
expect(chunks).toHaveLength(3)
expect(chunks[0].text).toBe('<section>one</section>')
expect(chunks[1].text).toBe('<section>two</section>')
expect(chunks[2].text).toBe('<section>three</section>')
})
it.concurrent('should leave non-capturing groups and lookarounds intact', async () => {
const chunker = new RegexChunker({
pattern: '(?=\\n\\s*\\{\\s*"id"\\s*:)',
chunkSize: 1024,
strictBoundaries: true,
})
const text = '{"id": 1, "v": "a"}\n{"id": 2, "v": "b"}\n{"id": 3, "v": "c"}'
const chunks = await chunker.chunk(text)
expect(chunks).toHaveLength(3)
})
})
describe('strictBoundaries mode', () => {
it.concurrent(
'should produce one chunk per match without merging small adjacent segments',
async () => {
const chunker = new RegexChunker({
pattern: '\\n\\n',
chunkSize: 1024,
strictBoundaries: true,
})
const text = 'Short.\n\nAlso short.\n\nTiny.\n\nSmall too.'
const chunks = await chunker.chunk(text)
expect(chunks).toHaveLength(4)
expect(chunks[0].text).toBe('Short.')
expect(chunks[1].text).toBe('Also short.')
expect(chunks[2].text).toBe('Tiny.')
expect(chunks[3].text).toBe('Small too.')
}
)
it.concurrent('should produce one chunk per QA record using lookahead pattern', async () => {
const chunker = new RegexChunker({
pattern: '(?=\\n\\s*\\{\\s*"id"\\s*:)',
chunkSize: 1024,
strictBoundaries: true,
})
const text =
'{"id": 1, "q": "first?", "a": "yes"}\n{"id": 2, "q": "second?", "a": "no"}\n{"id": 3, "q": "third?", "a": "maybe"}'
const chunks = await chunker.chunk(text)
expect(chunks).toHaveLength(3)
expect(chunks[0].text).toContain('"id": 1')
expect(chunks[0].text).not.toContain('"id": 2')
expect(chunks[1].text).toContain('"id": 2')
expect(chunks[1].text).not.toContain('"id": 3')
expect(chunks[2].text).toContain('"id": 3')
})
it.concurrent('should not apply overlap even when chunkOverlap is set', async () => {
const chunker = new RegexChunker({
pattern: '\\n\\n',
chunkSize: 100,
chunkOverlap: 50,
strictBoundaries: true,
})
const text = 'First section content.\n\nSecond section content.\n\nThird section content.'
const chunks = await chunker.chunk(text)
expect(chunks).toHaveLength(3)
expect(chunks[0].text).toBe('First section content.')
expect(chunks[1].text).toBe('Second section content.')
expect(chunks[2].text).toBe('Third section content.')
})
it.concurrent(
'should still split when content fits in single chunk if matches exist',
async () => {
const chunker = new RegexChunker({
pattern: '\\n\\n',
chunkSize: 1024,
strictBoundaries: true,
})
const text = 'A.\n\nB.\n\nC.'
const chunks = await chunker.chunk(text)
expect(chunks).toHaveLength(3)
}
)
it.concurrent('should sub-chunk a single oversized segment at word boundaries', async () => {
const chunker = new RegexChunker({
pattern: '---',
chunkSize: 10,
strictBoundaries: true,
})
const longSegment =
'This is a very long segment with many words that exceeds the chunk size limit significantly.'
const text = `${longSegment}---short`
const chunks = await chunker.chunk(text)
expect(chunks.length).toBeGreaterThan(2)
expect(chunks[chunks.length - 1].text).toBe('short')
})
it.concurrent('should return single chunk when regex finds no matches', async () => {
const chunker = new RegexChunker({
pattern: '###NOMATCH###',
chunkSize: 1024,
strictBoundaries: true,
})
const text = 'Plain text with no delimiter at all.'
const chunks = await chunker.chunk(text)
expect(chunks).toHaveLength(1)
expect(chunks[0].text).toBe(text)
})
it.concurrent('should return empty array for empty input', async () => {
const chunker = new RegexChunker({
pattern: '\\n\\n',
strictBoundaries: true,
})
const chunks = await chunker.chunk('')
expect(chunks).toEqual([])
})
it.concurrent(
'should default to merging behavior when strictBoundaries is omitted',
async () => {
const chunker = new RegexChunker({ pattern: '\\n\\n', chunkSize: 100 })
const text = 'Short.\n\nAlso short.\n\nTiny.\n\nSmall too.'
const chunks = await chunker.chunk(text)
expect(chunks).toHaveLength(1)
}
)
it.concurrent('should produce non-overlapping startIndex/endIndex metadata', async () => {
const chunker = new RegexChunker({
pattern: '\\n\\n',
chunkSize: 1024,
chunkOverlap: 50,
strictBoundaries: true,
})
const text = 'First.\n\nSecond.\n\nThird.'
const chunks = await chunker.chunk(text)
for (let i = 1; i < chunks.length; i++) {
expect(chunks[i].metadata.startIndex).toBeGreaterThanOrEqual(
chunks[i - 1].metadata.endIndex
)
}
})
})
})