Skip to content

Commit c2733fc

Browse files
committed
fix(context): size prompt and chunk budgets by script density
1 parent b699493 commit c2733fc

12 files changed

Lines changed: 507 additions & 32 deletions

__tests__/contextWindow.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import {
2+
estimatePromptTokens,
3+
getContextWindowTokens,
4+
getPromptCharBudget,
5+
} from '../constants/context-window';
6+
import { Model } from '../database/modelRepository';
7+
8+
const makeModel = (family?: string): Model => ({
9+
id: 1,
10+
modelName: 'Test',
11+
family,
12+
source: 'remote',
13+
isDownloaded: true,
14+
modelPath: '',
15+
tokenizerPath: '',
16+
tokenizerConfigPath: '',
17+
});
18+
19+
describe('getContextWindowTokens', () => {
20+
it('caps every current export family at 2048', () => {
21+
for (const family of [
22+
'Qwen 3',
23+
'Qwen 2.5',
24+
'LLaMA 3.2',
25+
'LFM 2.5',
26+
'Bielik',
27+
'Gemma 4',
28+
]) {
29+
expect(getContextWindowTokens(makeModel(family))).toBe(2048);
30+
}
31+
});
32+
33+
it('keeps the 2048 default for unknown or imported models', () => {
34+
expect(getContextWindowTokens(makeModel(undefined))).toBe(2048);
35+
expect(getContextWindowTokens(makeModel('Some Custom Family'))).toBe(2048);
36+
});
37+
});
38+
39+
describe('estimatePromptTokens', () => {
40+
it('is calibrated to the density measured on device for Polish', () => {
41+
const polish =
42+
'Dyrektorem finansowym spółki Zephyria jest Marta Kowalczyk-Nowak, ' +
43+
'powołana na to stanowisko 12 marca 2024 roku. Odpowiada za politykę ' +
44+
'budżetową oraz raportowanie kwartalne do rady nadzorczej. ';
45+
const sample = polish
46+
.repeat(Math.ceil(8320 / polish.length))
47+
.slice(0, 8320);
48+
49+
const density = estimatePromptTokens(sample) / sample.length;
50+
51+
expect(density).toBeGreaterThan(0.26);
52+
expect(density).toBeLessThan(0.34);
53+
});
54+
55+
it('charges CJK far more per character than Latin', () => {
56+
const latin = 'the quick brown fox jumps over the lazy dog'.repeat(10);
57+
const chinese = '泽菲里亚能源公司在波兰设有三个生产基地'.repeat(10);
58+
59+
const latinPerChar = estimatePromptTokens(latin) / latin.length;
60+
const chinesePerChar = estimatePromptTokens(chinese) / chinese.length;
61+
62+
expect(latinPerChar).toBeLessThan(0.3);
63+
expect(chinesePerChar).toBeGreaterThanOrEqual(0.9);
64+
});
65+
66+
it('handles surrogate pairs as single code points', () => {
67+
expect(estimatePromptTokens('🚀')).toBe(1);
68+
});
69+
70+
it('returns zero for empty text', () => {
71+
expect(estimatePromptTokens('')).toBe(0);
72+
});
73+
});
74+
75+
describe('getPromptCharBudget', () => {
76+
it('falls back to a safe Latin density when given no sample', () => {
77+
expect(getPromptCharBudget(makeModel('Gemma 4'))).toBe(3840);
78+
expect(getPromptCharBudget(makeModel('Qwen 2.5'))).toBe(3840);
79+
});
80+
81+
it('grants Latin prose more characters than the flat fallback', () => {
82+
const english =
83+
'The quarterly report covers revenue, headcount and logistics. '.repeat(
84+
20
85+
);
86+
87+
expect(getPromptCharBudget(makeModel('Gemma 4'), english)).toBeGreaterThan(
88+
3840
89+
);
90+
});
91+
92+
it('shrinks the budget for CJK so the prompt stays under the cap', () => {
93+
const chinese = '泽菲里亚能源公司在波兰设有三个生产基地。'.repeat(20);
94+
95+
const budget = getPromptCharBudget(makeModel('Gemma 4'), chinese);
96+
97+
expect(budget).toBeLessThanOrEqual(1280);
98+
const filled = chinese
99+
.repeat(Math.ceil(budget / chinese.length) + 1)
100+
.slice(0, budget);
101+
expect(estimatePromptTokens(filled)).toBeLessThanOrEqual(1280);
102+
});
103+
104+
it('keeps any script under the prompt token budget when filled to capacity', () => {
105+
const samples = [
106+
'Zephyria energetyka odnawialna sprawozdanie kwartalne. ',
107+
'Компания Зефирия управляет тремя заводами в Польше. ',
108+
'شركة زفيريا تدير ثلاثة مصانع في بولندا. ',
109+
'泽菲里亚能源公司在波兰设有三个生产基地。',
110+
'ゼフィリア・エナジーはポーランドに三つの工場を持っています。',
111+
'บริษัทเซฟีเรียมีโรงงานสามแห่งในโปแลนด์ ',
112+
];
113+
114+
for (const sample of samples) {
115+
const text = sample.repeat(200);
116+
const budget = getPromptCharBudget(makeModel('Gemma 4'), text);
117+
const filled = text.slice(0, budget);
118+
expect(estimatePromptTokens(filled)).toBeLessThanOrEqual(1280);
119+
}
120+
});
121+
});

__tests__/lfmEmbeddings.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { LFMEmbeddings } from '../utils/lfmEmbeddings';
2+
import { estimatePromptTokens } from '../constants/context-window';
3+
import { EMBEDDING_CHUNK_TOKEN_BUDGET } from '../constants/retrieval';
4+
5+
const makeEmbeddings = () =>
6+
new LFMEmbeddings({
7+
modelSource: 'file://embedding-model.pte',
8+
tokenizerSource: 'file://tokenizer.json',
9+
});
10+
11+
describe('embedding input limits', () => {
12+
it('sends a short query through with its prefix intact', async () => {
13+
const embeddings = makeEmbeddings();
14+
const embed = jest.spyOn(embeddings, 'embed').mockResolvedValue([0.1, 0.2]);
15+
16+
await embeddings.embedQuery('Kto jest dyrektorem finansowym?');
17+
18+
expect(embed).toHaveBeenCalledWith(
19+
'query: Kto jest dyrektorem finansowym?'
20+
);
21+
});
22+
23+
it('trims a query that would exceed the embedder sequence cap', async () => {
24+
const embeddings = makeEmbeddings();
25+
const embed = jest.spyOn(embeddings, 'embed').mockResolvedValue([0.1, 0.2]);
26+
const longQuery = '泽菲里亚能源公司在波兰设有三个生产基地。'.repeat(40);
27+
28+
await embeddings.embedQuery(longQuery);
29+
30+
const sent = embed.mock.calls[0]![0] as string;
31+
expect(sent.length).toBeLessThan(longQuery.length);
32+
expect(estimatePromptTokens(sent)).toBeLessThanOrEqual(
33+
EMBEDDING_CHUNK_TOKEN_BUDGET
34+
);
35+
});
36+
});

__tests__/ragPipeline.integration.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ describe('buildMessageSources — retrieval → context → citation pipeline',
105105
expect(preferredSourceDocuments).toEqual([]);
106106
});
107107

108-
it('prepends the attachment overview and orders the attachment first in the citations', async () => {
108+
it('appends the attachment overview after retrieval and orders the attachment first in the citations', async () => {
109109
const vectorStore = makeVectorStore([
110110
{
111111
id: '1:0',
@@ -139,7 +139,8 @@ describe('buildMessageSources — retrieval → context → citation pipeline',
139139
embeddings: null,
140140
});
141141

142-
expect(context[0]).toContain(
142+
expect(context[0]).toContain('--- Source 1:');
143+
expect(context.at(-1)).toContain(
143144
'Current Attachment Source: attachment.txt (Overview)'
144145
);
145146
expect(sourceDocuments[0].documentId).toBe(2);
@@ -176,7 +177,7 @@ describe('buildMessageSources — retrieval → context → citation pipeline',
176177
});
177178

178179
expect(sourceDocuments.map((d) => d.documentId)).toEqual([2, 1]);
179-
expect(context[0]).toContain('attachment.pdf (Overview)');
180+
expect(context.at(-1)).toContain('attachment.pdf (Overview)');
180181
});
181182

182183
it('takes the attachment-only path when there is no user query', async () => {

__tests__/sourceStore.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import type { SQLiteDatabase } from 'expo-sqlite';
77
import type { OPSQLiteVectorStore } from '@react-native-rag/op-sqlite';
88
import type { LFMEmbeddings } from '../utils/lfmEmbeddings';
99
import {
10+
EMBEDDING_CHUNK_TOKEN_BUDGET,
1011
MAX_SOURCE_CHUNKS,
1112
MAX_SOURCE_TEXT_CHARS,
1213
} from '../constants/retrieval';
14+
import { estimatePromptTokens } from '../constants/context-window';
1315

1416
jest.mock('../database/sourcesRepository');
1517
jest.mock('../utils/fileReaders');
@@ -187,6 +189,47 @@ describe('addSource', () => {
187189
expect(splitText.mock.calls[0][0]).toHaveLength(MAX_SOURCE_TEXT_CHARS);
188190
});
189191

192+
it('shrinks the splitter chunk size for a document in a dense script', async () => {
193+
mockReadDocumentText.mockResolvedValue(
194+
'泽菲里亚能源公司在波兰的三个城市设有生产基地。'.repeat(40)
195+
);
196+
mockInsertSource.mockResolvedValue(99);
197+
MockSplitter.mockImplementation(() => ({
198+
splitText: jest.fn().mockResolvedValue(['chunk']),
199+
}));
200+
201+
await useSourceStore
202+
.getState()
203+
.addSource(baseSource, '/path/doc.txt', mockVectorStore);
204+
205+
const { chunkSize, chunkOverlap } = MockSplitter.mock.calls[0][0];
206+
expect(chunkSize).toBeLessThanOrEqual(EMBEDDING_CHUNK_TOKEN_BUDGET);
207+
expect(chunkOverlap).toBeLessThan(chunkSize);
208+
});
209+
210+
it('splits a chunk that still exceeds the embedding token budget', async () => {
211+
const dense = '泽菲里亚能源公司在波兰的三个城市设有生产基地。'.repeat(30);
212+
mockReadDocumentText.mockResolvedValue(`plain ascii content ${dense}`);
213+
mockInsertSource.mockResolvedValue(99);
214+
MockSplitter.mockImplementation(() => ({
215+
splitText: jest.fn().mockResolvedValue([dense]),
216+
}));
217+
const embedDocument = jest.fn().mockResolvedValue([0.1]);
218+
219+
await useSourceStore
220+
.getState()
221+
.addSource(baseSource, '/path/doc.txt', mockVectorStore, {
222+
embedDocument,
223+
} as unknown as LFMEmbeddings);
224+
225+
expect(embedDocument.mock.calls.length).toBeGreaterThan(1);
226+
for (const [text] of embedDocument.mock.calls) {
227+
expect(estimatePromptTokens(text)).toBeLessThanOrEqual(
228+
EMBEDDING_CHUNK_TOKEN_BUDGET
229+
);
230+
}
231+
});
232+
190233
it('aborts embedding and rolls back the partial source when the signal is aborted', async () => {
191234
mockReadDocumentText.mockResolvedValue('content');
192235
mockInsertSource.mockResolvedValue(99);

__tests__/textChunking.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { estimatePromptTokens } from '../constants/context-window';
2+
import {
3+
EMBEDDING_CHUNK_TOKEN_BUDGET,
4+
MIN_TEXT_SPLITTER_CHUNK_SIZE,
5+
TEXT_SPLITTER_CHUNK_SIZE,
6+
} from '../constants/retrieval';
7+
import {
8+
capChunksToTokenBudget,
9+
getChunkCharOverlap,
10+
getChunkCharSize,
11+
truncateToTokenBudget,
12+
} from '../utils/textChunking';
13+
14+
const LATIN =
15+
'The quarterly report covers revenue, headcount and logistics across three plants. ';
16+
const CHINESE =
17+
'泽菲里亚能源公司在波兰的三个城市设有生产基地,主要生产光伏组件和储能系统。';
18+
const POLISH =
19+
'Dyrektorem finansowym spółki Zephyria jest Marta Kowalczyk-Nowak, powołana w marcu. ';
20+
21+
describe('getChunkCharSize', () => {
22+
it('keeps the full character size for Latin prose', () => {
23+
expect(getChunkCharSize(LATIN.repeat(20))).toBe(TEXT_SPLITTER_CHUNK_SIZE);
24+
});
25+
26+
it('shrinks the size for Chinese, which costs ~4x the tokens per character', () => {
27+
const size = getChunkCharSize(CHINESE.repeat(20));
28+
29+
expect(size).toBeLessThanOrEqual(EMBEDDING_CHUNK_TOKEN_BUDGET);
30+
expect(size).toBeGreaterThanOrEqual(MIN_TEXT_SPLITTER_CHUNK_SIZE);
31+
});
32+
33+
it('lands between the two for a mixed-script document', () => {
34+
const mixed = `${LATIN.repeat(10)}${CHINESE.repeat(10)}`;
35+
const size = getChunkCharSize(mixed);
36+
37+
expect(size).toBeGreaterThan(getChunkCharSize(CHINESE.repeat(20)));
38+
expect(size).toBeLessThan(TEXT_SPLITTER_CHUNK_SIZE);
39+
});
40+
41+
it('never returns a size whose chunk would exceed the token budget', () => {
42+
for (const sample of [LATIN, POLISH, CHINESE, '🚀🚀🚀 ', 'абвгд ']) {
43+
const text = sample.repeat(60);
44+
const chunk = text.slice(0, getChunkCharSize(text));
45+
expect(estimatePromptTokens(chunk)).toBeLessThanOrEqual(
46+
EMBEDDING_CHUNK_TOKEN_BUDGET
47+
);
48+
}
49+
});
50+
51+
it('falls back to the full size for empty text', () => {
52+
expect(getChunkCharSize('')).toBe(TEXT_SPLITTER_CHUNK_SIZE);
53+
});
54+
});
55+
56+
describe('getChunkCharOverlap', () => {
57+
it('keeps the splitter overlap proportional to the chunk size', () => {
58+
expect(getChunkCharOverlap(TEXT_SPLITTER_CHUNK_SIZE)).toBe(200);
59+
expect(getChunkCharOverlap(250)).toBe(50);
60+
});
61+
});
62+
63+
describe('capChunksToTokenBudget', () => {
64+
it('leaves chunks that already fit untouched', () => {
65+
const chunks = [LATIN, POLISH];
66+
expect(capChunksToTokenBudget(chunks)).toEqual(chunks);
67+
});
68+
69+
it('splits a dense passage hiding inside an otherwise Latin document', () => {
70+
const oversized = CHINESE.repeat(30);
71+
72+
const capped = capChunksToTokenBudget([LATIN, oversized]);
73+
74+
expect(capped.length).toBeGreaterThan(2);
75+
for (const chunk of capped) {
76+
expect(estimatePromptTokens(chunk)).toBeLessThanOrEqual(
77+
EMBEDDING_CHUNK_TOKEN_BUDGET
78+
);
79+
}
80+
});
81+
82+
it('preserves the text across the split', () => {
83+
const oversized = CHINESE.repeat(30);
84+
expect(capChunksToTokenBudget([oversized]).join('')).toBe(oversized);
85+
});
86+
87+
it('does not drop a character that alone exceeds the budget', () => {
88+
expect(capChunksToTokenBudget(['字'], 0.5)).toEqual(['字']);
89+
});
90+
});
91+
92+
describe('truncateToTokenBudget', () => {
93+
it('returns short text unchanged', () => {
94+
expect(truncateToTokenBudget('Kto jest dyrektorem finansowym?')).toBe(
95+
'Kto jest dyrektorem finansowym?'
96+
);
97+
});
98+
99+
it('trims an over-long query to the budget', () => {
100+
const query = CHINESE.repeat(40);
101+
102+
const truncated = truncateToTokenBudget(query);
103+
104+
expect(truncated.length).toBeLessThan(query.length);
105+
expect(query.startsWith(truncated)).toBe(true);
106+
expect(estimatePromptTokens(truncated)).toBeLessThanOrEqual(
107+
EMBEDDING_CHUNK_TOKEN_BUDGET
108+
);
109+
});
110+
111+
it('handles empty text', () => {
112+
expect(truncateToTokenBudget('')).toBe('');
113+
});
114+
});

0 commit comments

Comments
 (0)