Skip to content

Commit f0fd48c

Browse files
authored
fix(knowledge): bound chunking separators so one config can't stall processing (#6735)
* fix(knowledge): bound chunking separators so one config can't stall processing `chunkingStrategyOptionsSchema.separators` accepted an arbitrary-length array of arbitrary-length strings, next to a `pattern` field already capped at 500 chars. `RecursiveChunker` splits the whole document once per separator and walks the list from the top for every oversized fragment, so a persisted config with thousands of non-matching separators cost seconds of synchronous CPU on every later document upload — work neither the processing `Promise.race` timeout nor the after-the-fact chunk-count cap can interrupt. Measured on a 21.3 MB document: 632 ms at 100 separators, 6.1 s at 1000, 36.8 s at 5000. - Bound `separators` to 32 entries of at most 100 characters on the write path. The largest built-in recipe (markdown) uses 16, so hand-tuned lists still fit. - Keep the stored/read shape tolerant, so a config written before the bound still lists instead of failing response validation. - Clamp in `RecursiveChunker` too, with a warning, so an already-persisted oversized list cannot reach the split loop. An over-long separator is dropped rather than truncated: a truncated separator matches where the configured one never did, silently re-cutting the document, while dropping it behaves like a separator that finds no match. A list left empty falls back to the recipe. - Walk non-matching separators iteratively instead of recursing, so stack depth no longer tracks the separator count. Verified behavior-preserving against the previous implementation over 4000 randomized configs — byte-identical output. - Validate in the create-base modal so the limit surfaces inline. After the fix the same 21.3 MB document costs ~300 ms at every separator count. * fix(knowledge): gate separator validation on the recursive strategy - The separator refines ran for every strategy, but the field only renders for `recursive` and only that strategy submits it, so a value left behind by a strategy switch could block submit with no visible field to clear. Gated the same way the regex-pattern refine already is. - Use absolute imports in the chunker test, per the repo convention.
1 parent 0cd87ba commit f0fd48c

6 files changed

Lines changed: 272 additions & 23 deletions

File tree

apps/sim/app/workspace/[workspaceId]/knowledge/components/create-base-modal/create-base-modal.tsx

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { getErrorMessage } from '@sim/utils/errors'
2525
import { useParams } from 'next/navigation'
2626
import { type FieldErrors, useForm } from 'react-hook-form'
2727
import { z } from 'zod'
28+
import { MAX_CHUNKING_SEPARATOR_LENGTH, MAX_CHUNKING_SEPARATORS } from '@/lib/chunkers/constants'
2829
import type { StrategyOptions } from '@/lib/chunkers/types'
2930
import { KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH } from '@/lib/knowledge/constants'
3031
import {
@@ -57,6 +58,14 @@ const STRATEGY_OPTIONS = [
5758
{ value: 'regex', label: 'Regex (custom pattern)' },
5859
] as const
5960

61+
/** Splits the comma-separated separator field into the list the API receives. */
62+
function parseSeparators(value: string | undefined): string[] {
63+
if (!value?.trim()) return []
64+
return value
65+
.split(',')
66+
.map((separator) => separator.trim().replace(/\\n/g, '\n').replace(/\\t/g, '\t'))
67+
}
68+
6069
const STRATEGY_COMBOBOX_OPTIONS: ComboboxOption[] = STRATEGY_OPTIONS.map((o) => ({
6170
label: o.label,
6271
value: o.value,
@@ -124,6 +133,31 @@ const FormSchema = z
124133
path: ['regexPattern'],
125134
}
126135
)
136+
/**
137+
* Gated on the strategy for the same reason the regex pattern is: the field only
138+
* renders for `recursive` and only that strategy submits it, so an out-of-bound
139+
* value left behind by a strategy switch must not block a submit that drops it.
140+
*/
141+
.refine(
142+
(data) =>
143+
data.strategy !== 'recursive' ||
144+
parseSeparators(data.customSeparators).length <= MAX_CHUNKING_SEPARATORS,
145+
{
146+
message: `At most ${MAX_CHUNKING_SEPARATORS} separators are allowed`,
147+
path: ['customSeparators'],
148+
}
149+
)
150+
.refine(
151+
(data) =>
152+
data.strategy !== 'recursive' ||
153+
parseSeparators(data.customSeparators).every(
154+
(separator) => separator.length <= MAX_CHUNKING_SEPARATOR_LENGTH
155+
),
156+
{
157+
message: `Each separator must be ${MAX_CHUNKING_SEPARATOR_LENGTH} characters or less`,
158+
path: ['customSeparators'],
159+
}
160+
)
127161

128162
type FormInputValues = z.input<typeof FormSchema>
129163
type FormValues = z.output<typeof FormSchema>
@@ -265,11 +299,7 @@ export const CreateBaseModal = memo(function CreateBaseModal({
265299
...(data.regexStrictBoundaries && { strictBoundaries: true }),
266300
}
267301
: data.strategy === 'recursive' && data.customSeparators?.trim()
268-
? {
269-
separators: data.customSeparators
270-
.split(',')
271-
.map((s) => s.trim().replace(/\\n/g, '\n').replace(/\\t/g, '\t')),
272-
}
302+
? { separators: parseSeparators(data.customSeparators) }
273303
: undefined
274304

275305
const newKnowledgeBase = await createKnowledgeBaseMutation.mutateAsync({
@@ -465,11 +495,13 @@ export const CreateBaseModal = memo(function CreateBaseModal({
465495
<ChipModalField
466496
type='custom'
467497
title='Custom Separators (optional)'
468-
hint='Comma-separated list of delimiters in priority order. Leave empty for default separators.'
498+
hint={`Comma-separated list of delimiters in priority order, up to ${MAX_CHUNKING_SEPARATORS}. Leave empty for default separators.`}
499+
error={errors.customSeparators?.message}
469500
>
470501
<ChipInput
471502
placeholder='e.g. \n\n, \n, . , '
472503
{...register('customSeparators')}
504+
error={Boolean(errors.customSeparators)}
473505
autoComplete='off'
474506
data-form-type='other'
475507
/>
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
chunkingStrategyOptionsSchema,
7+
createKnowledgeBaseBodySchema,
8+
knowledgeBaseDataSchema,
9+
} from '@/lib/api/contracts/knowledge/base'
10+
import { MAX_CHUNKING_SEPARATOR_LENGTH, MAX_CHUNKING_SEPARATORS } from '@/lib/chunkers/constants'
11+
12+
const separators = (count: number) => Array.from({ length: count }, (_, i) => `@@sep${i}@@`)
13+
14+
describe('chunkingStrategyOptionsSchema.separators', () => {
15+
it('accepts a separator list at the bound', () => {
16+
const parsed = chunkingStrategyOptionsSchema.parse({
17+
separators: separators(MAX_CHUNKING_SEPARATORS),
18+
})
19+
expect(parsed.separators).toHaveLength(MAX_CHUNKING_SEPARATORS)
20+
})
21+
22+
it('rejects more separators than the bound', () => {
23+
const result = chunkingStrategyOptionsSchema.safeParse({
24+
separators: separators(MAX_CHUNKING_SEPARATORS + 1),
25+
})
26+
expect(result.success).toBe(false)
27+
})
28+
29+
it('rejects a separator longer than the per-item bound', () => {
30+
const result = chunkingStrategyOptionsSchema.safeParse({
31+
separators: ['|'.repeat(MAX_CHUNKING_SEPARATOR_LENGTH + 1)],
32+
})
33+
expect(result.success).toBe(false)
34+
})
35+
36+
it('rejects an oversized list on the knowledge base create body', () => {
37+
const result = createKnowledgeBaseBodySchema.safeParse({
38+
name: 'kb',
39+
workspaceId: 'ws',
40+
chunkingConfig: {
41+
maxSize: 1024,
42+
minSize: 100,
43+
overlap: 200,
44+
strategy: 'recursive',
45+
strategyOptions: { separators: separators(5000) },
46+
},
47+
})
48+
expect(result.success).toBe(false)
49+
})
50+
})
51+
52+
describe('knowledgeBaseDataSchema.chunkingConfig', () => {
53+
it('still reads a stored config written before the separator bound', () => {
54+
const result = knowledgeBaseDataSchema.safeParse({
55+
id: 'kb-1',
56+
userId: 'u-1',
57+
name: 'kb',
58+
description: null,
59+
tokenCount: 0,
60+
embeddingModel: 'text-embedding-3-small',
61+
embeddingDimension: 1536,
62+
chunkingConfig: {
63+
maxSize: 1024,
64+
minSize: 100,
65+
overlap: 200,
66+
strategy: 'recursive',
67+
strategyOptions: { separators: separators(5000) },
68+
},
69+
createdAt: new Date().toISOString(),
70+
updatedAt: new Date().toISOString(),
71+
deletedAt: null,
72+
workspaceId: 'ws',
73+
folderId: null,
74+
})
75+
expect(result.success).toBe(true)
76+
})
77+
})

apps/sim/lib/api/contracts/knowledge/base.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
workspaceIdSchema,
1212
} from '@/lib/api/contracts/primitives'
1313
import { defineRouteContract } from '@/lib/api/contracts/types'
14+
import { MAX_CHUNKING_SEPARATOR_LENGTH, MAX_CHUNKING_SEPARATORS } from '@/lib/chunkers/constants'
1415
import type { StrategyOptions } from '@/lib/chunkers/types'
1516
import {
1617
DEFAULT_CHUNKING_CONFIG,
@@ -26,7 +27,13 @@ export const listKnowledgeBasesQuerySchema = z.object({
2627
scope: knowledgeScopeSchema.default('active'),
2728
})
2829

29-
export const chunkingStrategyOptionsSchema = z
30+
/**
31+
* Strategy options as they are stored. Reads stay tolerant of a `separators`
32+
* list written before {@link chunkingStrategyOptionsSchema} bounded it, so an
33+
* oversized legacy config lists instead of failing response validation. The
34+
* chunker clamps such a list at construction, so nothing reprocesses unbounded.
35+
*/
36+
export const storedChunkingStrategyOptionsSchema = z
3037
.object({
3138
pattern: z
3239
.string()
@@ -48,6 +55,29 @@ export const chunkingStrategyOptionsSchema = z
4855
})
4956
.strict() satisfies z.ZodType<StrategyOptions>
5057

58+
/**
59+
* Strategy options accepted on writes. `separators` is bounded in both length
60+
* and item size: the recursive chunker rescans the whole document once per
61+
* separator, synchronously, so an unbounded list turns one persisted config
62+
* into seconds of uninterruptible CPU on every later document upload.
63+
*/
64+
export const chunkingStrategyOptionsSchema = storedChunkingStrategyOptionsSchema
65+
.extend({
66+
separators: z
67+
.array(
68+
z
69+
.string()
70+
.max(
71+
MAX_CHUNKING_SEPARATOR_LENGTH,
72+
`Each separator must be ${MAX_CHUNKING_SEPARATOR_LENGTH} characters or less`
73+
)
74+
)
75+
.max(MAX_CHUNKING_SEPARATORS, `At most ${MAX_CHUNKING_SEPARATORS} separators are allowed`)
76+
.optional()
77+
.describe('Ordered separators used to split content into chunks.'),
78+
})
79+
.strict() satisfies z.ZodType<StrategyOptions>
80+
5181
export const chunkingConfigSchema = z
5282
.object({
5383
maxSize: z.number().min(100).max(4000),
@@ -116,7 +146,7 @@ const knowledgeChunkingConfigSchema = z
116146
minSize: z.number(),
117147
overlap: z.number(),
118148
strategy: z.enum(['auto', 'text', 'regex', 'recursive', 'sentence', 'token']).optional(),
119-
strategyOptions: chunkingStrategyOptionsSchema.optional(),
149+
strategyOptions: storedChunkingStrategyOptionsSchema.optional(),
120150
})
121151
.passthrough()
122152

apps/sim/lib/chunkers/constants.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Bounds on the separator list a recursive chunking config may carry.
3+
*
4+
* `RecursiveChunker` scans the whole document once per separator and walks the
5+
* list from the top for every oversized fragment, so the separator count is a
6+
* direct multiplier on synchronous CPU per document. The work happens inside a
7+
* split loop, which neither the processing `Promise.race` timeout nor the
8+
* after-the-fact chunk-count cap can interrupt — the list has to be bounded on
9+
* the way in instead.
10+
*
11+
* The largest built-in recipe (`markdown`) uses 16 separators, so 32 leaves room
12+
* for a hand-tuned list without letting one config stall the processing tier.
13+
*/
14+
export const MAX_CHUNKING_SEPARATORS = 32
15+
16+
/** Max characters in a single chunking separator. Real delimiters are a few characters. */
17+
export const MAX_CHUNKING_SEPARATOR_LENGTH = 100

apps/sim/lib/chunkers/recursive-chunker.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
*/
44

55
import { describe, expect, it } from 'vitest'
6-
import { RecursiveChunker } from './recursive-chunker'
6+
import { MAX_CHUNKING_SEPARATOR_LENGTH, MAX_CHUNKING_SEPARATORS } from '@/lib/chunkers/constants'
7+
import { RecursiveChunker } from '@/lib/chunkers/recursive-chunker'
78

89
describe('RecursiveChunker', () => {
910
describe('empty and whitespace input', () => {
@@ -101,6 +102,63 @@ describe('RecursiveChunker', () => {
101102
})
102103
})
103104

105+
describe('separator bounds', () => {
106+
it.concurrent('ignores separators past the list bound', async () => {
107+
const separators = [
108+
...Array.from({ length: MAX_CHUNKING_SEPARATORS }, (_, i) => `@@nomatch${i}@@`),
109+
'---',
110+
]
111+
const chunker = new RecursiveChunker({ chunkSize: 15, separators })
112+
const text =
113+
'Section one content here with words.---Section two content here with words.---Section three content here.'
114+
115+
const chunks = await chunker.chunk(text)
116+
117+
expect(chunks.length).toBeGreaterThan(1)
118+
expect(chunks.some((chunk) => chunk.text.includes('---'))).toBe(true)
119+
})
120+
121+
it.concurrent('splits on a separator that survives the clamp', async () => {
122+
const separators = [
123+
'---',
124+
...Array.from({ length: MAX_CHUNKING_SEPARATORS }, (_, i) => `@@nomatch${i}@@`),
125+
]
126+
const chunker = new RecursiveChunker({ chunkSize: 15, separators })
127+
const text =
128+
'Section one content here with words.---Section two content here with words.---Section three content here.'
129+
130+
const chunks = await chunker.chunk(text)
131+
132+
expect(chunks.length).toBeGreaterThan(1)
133+
expect(chunks.every((chunk) => !chunk.text.includes('---'))).toBe(true)
134+
})
135+
136+
it.concurrent('drops a separator longer than the per-item bound', async () => {
137+
const oversized = '|'.repeat(MAX_CHUNKING_SEPARATOR_LENGTH + 1)
138+
const chunker = new RecursiveChunker({ chunkSize: 15, separators: [oversized, '---'] })
139+
const text = `Section one content here.${oversized}Section two content.---Section three content.`
140+
141+
const chunks = await chunker.chunk(text)
142+
143+
expect(chunks.some((chunk) => chunk.text.includes('|'))).toBe(true)
144+
expect(chunks.every((chunk) => !chunk.text.includes('---'))).toBe(true)
145+
})
146+
147+
it.concurrent('falls back to the recipe when every separator is over the bound', async () => {
148+
const oversized = '|'.repeat(MAX_CHUNKING_SEPARATOR_LENGTH + 1)
149+
const text =
150+
'Section one content here with words.\n\nSection two content here with words.\n\nSection three content.'
151+
152+
const chunks = await new RecursiveChunker({ chunkSize: 15, separators: [oversized] }).chunk(
153+
text
154+
)
155+
const defaultChunks = await new RecursiveChunker({ chunkSize: 15 }).chunk(text)
156+
157+
expect(chunks.length).toBeGreaterThan(1)
158+
expect(chunks).toEqual(defaultChunks)
159+
})
160+
})
161+
104162
describe('recipe: plain', () => {
105163
it.concurrent('should use plain recipe by default', async () => {
106164
const chunker = new RecursiveChunker({ chunkSize: 20 })

0 commit comments

Comments
 (0)