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
220 lines
6.3 KiB
TypeScript
220 lines
6.3 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { toError } from '@sim/utils/errors'
|
|
import type { Chunk, RegexChunkerOptions } from '@/lib/chunkers/types'
|
|
import {
|
|
addOverlap,
|
|
buildChunks,
|
|
cleanText,
|
|
estimateTokens,
|
|
resolveChunkerOptions,
|
|
splitAtWordBoundaries,
|
|
tokensToChars,
|
|
} from '@/lib/chunkers/utils'
|
|
|
|
const logger = createLogger('RegexChunker')
|
|
|
|
const MAX_PATTERN_LENGTH = 500
|
|
|
|
const NAMED_GROUP_PREFIX = /^\(\?<(?![=!])[^>]+>/
|
|
|
|
/**
|
|
* Converts unescaped capturing groups `(...)` and named capturing groups
|
|
* `(?<name>...)` into non-capturing groups `(?:...)`. `String.prototype.split()`
|
|
* interleaves captured text (named or otherwise) into the result array, which
|
|
* would surface delimiter text as spurious chunks. Lookarounds (`(?=`, `(?!`,
|
|
* `(?<=`, `(?<!`) and other `(?...)` constructs are left untouched.
|
|
*/
|
|
function toNonCapturing(pattern: string): string {
|
|
let result = ''
|
|
let inClass = false
|
|
for (let i = 0; i < pattern.length; i++) {
|
|
const c = pattern[i]
|
|
if (c === '\\' && i + 1 < pattern.length) {
|
|
result += c + pattern[i + 1]
|
|
i++
|
|
continue
|
|
}
|
|
if (c === '[') inClass = true
|
|
else if (c === ']') inClass = false
|
|
if (!inClass && c === '(') {
|
|
if (pattern[i + 1] !== '?') {
|
|
result += '(?:'
|
|
continue
|
|
}
|
|
const namedMatch = pattern.slice(i).match(NAMED_GROUP_PREFIX)
|
|
if (namedMatch) {
|
|
result += '(?:'
|
|
i += namedMatch[0].length - 1
|
|
continue
|
|
}
|
|
}
|
|
result += c
|
|
}
|
|
return result
|
|
}
|
|
|
|
export class RegexChunker {
|
|
private readonly chunkSize: number
|
|
private readonly chunkOverlap: number
|
|
private readonly regex: RegExp
|
|
private readonly strictBoundaries: boolean
|
|
|
|
constructor(options: RegexChunkerOptions) {
|
|
const resolved = resolveChunkerOptions(options)
|
|
this.chunkSize = resolved.chunkSize
|
|
this.chunkOverlap = resolved.chunkOverlap
|
|
this.regex = this.compilePattern(options.pattern)
|
|
this.strictBoundaries = options.strictBoundaries ?? false
|
|
}
|
|
|
|
private compilePattern(pattern: string): RegExp {
|
|
if (!pattern) {
|
|
throw new Error('Regex pattern is required')
|
|
}
|
|
|
|
if (pattern.length > MAX_PATTERN_LENGTH) {
|
|
throw new Error(`Regex pattern exceeds maximum length of ${MAX_PATTERN_LENGTH} characters`)
|
|
}
|
|
|
|
try {
|
|
const regex = new RegExp(toNonCapturing(pattern), 'g')
|
|
|
|
const testStrings = [
|
|
'a'.repeat(10000),
|
|
' '.repeat(10000),
|
|
'a '.repeat(5000),
|
|
'aB1 xY2\n'.repeat(1250),
|
|
`${'a'.repeat(30)}!`,
|
|
`${'a b '.repeat(25)}!`,
|
|
]
|
|
for (const testStr of testStrings) {
|
|
regex.lastIndex = 0
|
|
const start = Date.now()
|
|
regex.test(testStr)
|
|
const elapsed = Date.now() - start
|
|
if (elapsed > 50) {
|
|
throw new Error('Regex pattern appears to have catastrophic backtracking')
|
|
}
|
|
}
|
|
|
|
regex.lastIndex = 0
|
|
return regex
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message.includes('catastrophic')) {
|
|
throw error
|
|
}
|
|
throw new Error(`Invalid regex pattern "${pattern}": ${toError(error).message}`)
|
|
}
|
|
}
|
|
|
|
async chunk(content: string): Promise<Chunk[]> {
|
|
if (!content?.trim()) {
|
|
return []
|
|
}
|
|
|
|
const cleaned = cleanText(content)
|
|
|
|
if (!this.strictBoundaries && estimateTokens(cleaned) <= this.chunkSize) {
|
|
logger.info('Content fits in single chunk')
|
|
return buildChunks([cleaned], 0)
|
|
}
|
|
|
|
this.regex.lastIndex = 0
|
|
const segments = cleaned.split(this.regex).filter((s) => s.trim().length > 0)
|
|
|
|
if (segments.length <= 1) {
|
|
if (this.strictBoundaries) {
|
|
logger.info('Regex pattern produced no splits in strict mode, returning single chunk')
|
|
return buildChunks([cleaned.trim()], 0)
|
|
}
|
|
logger.warn(
|
|
'Regex pattern did not produce any splits, falling back to word-boundary splitting'
|
|
)
|
|
const chunkSizeChars = tokensToChars(this.chunkSize)
|
|
let chunks = splitAtWordBoundaries(cleaned, chunkSizeChars)
|
|
if (this.chunkOverlap > 0) {
|
|
const overlapChars = tokensToChars(this.chunkOverlap)
|
|
chunks = addOverlap(chunks, overlapChars)
|
|
}
|
|
return buildChunks(chunks, this.chunkOverlap)
|
|
}
|
|
|
|
if (this.strictBoundaries) {
|
|
const chunks = this.expandOversizedSegments(segments)
|
|
logger.info(`Chunked into ${chunks.length} strict-boundary regex chunks`)
|
|
return buildChunks(chunks, 0)
|
|
}
|
|
|
|
const merged = this.mergeSegments(segments)
|
|
|
|
let chunks = merged
|
|
if (this.chunkOverlap > 0) {
|
|
const overlapChars = tokensToChars(this.chunkOverlap)
|
|
chunks = addOverlap(chunks, overlapChars)
|
|
}
|
|
|
|
logger.info(`Chunked into ${chunks.length} regex-based chunks`)
|
|
return buildChunks(chunks, this.chunkOverlap)
|
|
}
|
|
|
|
/**
|
|
* In strict-boundary mode each segment becomes its own chunk. Segments that
|
|
* exceed chunkSize are still split at word boundaries to preserve the token
|
|
* limit invariant; this is a safety floor, not a merge.
|
|
*/
|
|
private expandOversizedSegments(segments: string[]): string[] {
|
|
const result: string[] = []
|
|
const chunkSizeChars = tokensToChars(this.chunkSize)
|
|
|
|
for (const segment of segments) {
|
|
const trimmed = segment.trim()
|
|
if (!trimmed) continue
|
|
|
|
if (estimateTokens(trimmed) <= this.chunkSize) {
|
|
result.push(trimmed)
|
|
} else {
|
|
const subChunks = splitAtWordBoundaries(trimmed, chunkSizeChars)
|
|
for (const sub of subChunks) {
|
|
if (sub.trim()) result.push(sub)
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
private mergeSegments(segments: string[]): string[] {
|
|
const chunks: string[] = []
|
|
let current = ''
|
|
|
|
for (const segment of segments) {
|
|
const test = current ? `${current}\n${segment}` : segment
|
|
|
|
if (estimateTokens(test) <= this.chunkSize) {
|
|
current = test
|
|
} else {
|
|
if (current.trim()) {
|
|
chunks.push(current.trim())
|
|
}
|
|
|
|
if (estimateTokens(segment) > this.chunkSize) {
|
|
const chunkSizeChars = tokensToChars(this.chunkSize)
|
|
const subChunks = splitAtWordBoundaries(segment, chunkSizeChars)
|
|
for (const sub of subChunks) {
|
|
chunks.push(sub)
|
|
}
|
|
current = ''
|
|
} else {
|
|
current = segment
|
|
}
|
|
}
|
|
}
|
|
|
|
if (current.trim()) {
|
|
chunks.push(current.trim())
|
|
}
|
|
|
|
return chunks
|
|
}
|
|
}
|