@eottabom/tokenizer-core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -0
- package/bpe.js +147 -0
- package/cli.js +99 -0
- package/data/cl100k_base.json +1 -0
- package/data/claude.json +1 -0
- package/data/gemma3.json +1 -0
- package/data/o200k_base.json +1 -0
- package/data/p50k_base.json +1 -0
- package/hf-bpe.js +232 -0
- package/index.js +89 -0
- package/package.json +28 -0
- package/tiktoken.js +127 -0
package/hf-bpe.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hf-bpe.js - HuggingFace tokenizer.json 포맷 토크나이저
|
|
3
|
+
*
|
|
4
|
+
* HuggingFace의 tokenizer.json은 다음 파이프라인으로 동작합니다:
|
|
5
|
+
* 1. Normalizer - 텍스트 정규화 (예: NFKC, 공백→▁ 치환)
|
|
6
|
+
* 2. PreTokenizer - 텍스트를 단어/청크로 분리
|
|
7
|
+
* 3. Model (BPE) - merge 목록 기반 BPE로 토큰화
|
|
8
|
+
* 4. PostProcessor - BOS/EOS 등 특수 토큰 추가
|
|
9
|
+
*
|
|
10
|
+
* 이 파일은 Claude(ByteLevel BPE)와 Gemma3(SentencePiece BPE) 두 가지를 지원합니다.
|
|
11
|
+
*
|
|
12
|
+
* ┌─────────────┬──────────────────────┬───────────────────────┐
|
|
13
|
+
* │ │ Claude │ Gemma3 │
|
|
14
|
+
* ├─────────────┼──────────────────────┼───────────────────────┤
|
|
15
|
+
* │ Normalizer │ NFKC │ Replace " "→"▁" │
|
|
16
|
+
* │ PreTokenizer│ ByteLevel (GPT-2식) │ 전체를 하나로 처리 │
|
|
17
|
+
* │ Vocab 크기 │ 65,000 │ 262,144 │
|
|
18
|
+
* │ Merge 수 │ 64,739 │ 514,906 │
|
|
19
|
+
* │ BOS 토큰 │ 없음 │ <bos> (id=2) 자동 추가 │
|
|
20
|
+
* └─────────────┴──────────────────────┴───────────────────────┘
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { mergeBPE } from './bpe.js';
|
|
24
|
+
|
|
25
|
+
// ============================================================
|
|
26
|
+
// GPT-2 Byte-to-Unicode 매핑
|
|
27
|
+
// ============================================================
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* GPT-2의 byte_to_unicode 매핑 테이블 생성
|
|
31
|
+
*
|
|
32
|
+
* ByteLevel BPE(Claude, GPT-2 등)에서 사용되는 핵심 매핑입니다.
|
|
33
|
+
* 모든 256개 바이트를 출력 가능한 유니코드 문자로 매핑합니다.
|
|
34
|
+
*
|
|
35
|
+
* 동작 원리:
|
|
36
|
+
* - 출력 가능한 ASCII (33~126, 161~172, 174~255) → 그 자체를 문자로 사용
|
|
37
|
+
* - 나머지 바이트 (0~32, 127~160, 173) → U+0100부터 순서대로 매핑
|
|
38
|
+
*
|
|
39
|
+
* 예: 바이트 0x20 (공백) → U+0120 (Ġ)
|
|
40
|
+
* 바이트 0x41 (A) → 'A' (출력 가능하므로 그대로)
|
|
41
|
+
*
|
|
42
|
+
* 이렇게 하면 모든 바이트열을 "보이는 문자열"로 표현할 수 있어,
|
|
43
|
+
* vocab에 바이트 시퀀스를 문자열 키로 저장할 수 있습니다.
|
|
44
|
+
*/
|
|
45
|
+
function buildByteToUnicode() {
|
|
46
|
+
const bs = [];
|
|
47
|
+
// 출력 가능한 바이트 범위 수집
|
|
48
|
+
for (let i = 33; i <= 126; i++) bs.push(i); // ! ~ ~
|
|
49
|
+
for (let i = 161; i <= 172; i++) bs.push(i); // ¡ ~ ¬
|
|
50
|
+
for (let i = 174; i <= 255; i++) bs.push(i); // ® ~ ÿ
|
|
51
|
+
|
|
52
|
+
const cs = [...bs]; // 매핑될 유니코드 코드포인트
|
|
53
|
+
let n = 0;
|
|
54
|
+
|
|
55
|
+
// 위에서 포함되지 않은 바이트(비출력)는 U+0100부터 순서대로 매핑
|
|
56
|
+
for (let b = 0; b < 256; b++) {
|
|
57
|
+
if (!bs.includes(b)) {
|
|
58
|
+
bs.push(b);
|
|
59
|
+
cs.push(256 + n); // U+0100, U+0101, ...
|
|
60
|
+
n++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const byteToUni = {};
|
|
65
|
+
for (let i = 0; i < bs.length; i++) {
|
|
66
|
+
byteToUni[bs[i]] = String.fromCharCode(cs[i]);
|
|
67
|
+
}
|
|
68
|
+
return byteToUni;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 바이트(0~255) → 유니코드 문자 매핑 테이블 */
|
|
72
|
+
const BYTE_TO_UNICODE = buildByteToUnicode();
|
|
73
|
+
const textEncoder = new TextEncoder();
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* GPT-2 스타일 정규식 (ByteLevel pre-tokenizer용)
|
|
77
|
+
* 영어 축약형('s, 't 등), 단어, 숫자, 공백 등을 단위로 분리합니다.
|
|
78
|
+
*/
|
|
79
|
+
const BYTE_LEVEL_REGEX =
|
|
80
|
+
/'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+/gu;
|
|
81
|
+
|
|
82
|
+
// ============================================================
|
|
83
|
+
// HFTokenizer 클래스
|
|
84
|
+
// ============================================================
|
|
85
|
+
|
|
86
|
+
export class HFTokenizer {
|
|
87
|
+
/**
|
|
88
|
+
* @param {object} data - tokenizer.json에서 로드한 전체 데이터
|
|
89
|
+
*/
|
|
90
|
+
constructor(data) {
|
|
91
|
+
/** 토큰 문자열 → ID 매핑 (예: "hello" → 1234) */
|
|
92
|
+
this.vocab = data.model.vocab;
|
|
93
|
+
|
|
94
|
+
// Normalizer 설정
|
|
95
|
+
this.normalizerType = data.normalizer?.type;
|
|
96
|
+
this.normalizerPattern = data.normalizer?.pattern?.String;
|
|
97
|
+
this.normalizerContent = data.normalizer?.content;
|
|
98
|
+
|
|
99
|
+
// PreTokenizer 설정
|
|
100
|
+
this.preTokenizerType = data.pre_tokenizer?.type;
|
|
101
|
+
|
|
102
|
+
// Merge 규칙 → 우선순위(인덱스) 맵 구축
|
|
103
|
+
// merges는 문자열("Ġ t") 또는 배열(["▁", "이"]) 형식일 수 있음
|
|
104
|
+
this.mergeRanks = new Map();
|
|
105
|
+
for (let i = 0; i < data.model.merges.length; i++) {
|
|
106
|
+
const m = data.model.merges[i];
|
|
107
|
+
const key = Array.isArray(m) ? m.join(' ') : m;
|
|
108
|
+
this.mergeRanks.set(key, i);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// added_tokens: BPE로 분할되지 않는 특수/사전정의 토큰
|
|
112
|
+
this.addedTokens = new Map();
|
|
113
|
+
if (data.added_tokens) {
|
|
114
|
+
for (const t of data.added_tokens) {
|
|
115
|
+
this.addedTokens.set(t.content, t.id);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// PostProcessor: BOS(Beginning of Sequence) 토큰 자동 추가 여부 확인
|
|
120
|
+
// Gemma3는 모든 인코딩 앞에 <bos> 토큰(id=2)을 붙임
|
|
121
|
+
this.bosTokenId = null;
|
|
122
|
+
const pp = data.post_processor;
|
|
123
|
+
if (pp?.type === 'TemplateProcessing' && pp.single?.[0]?.SpecialToken) {
|
|
124
|
+
const bosContent = pp.single[0].SpecialToken.id;
|
|
125
|
+
const bosToken = data.added_tokens?.find(t => t.content === bosContent);
|
|
126
|
+
if (bosToken) this.bosTokenId = bosToken.id;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 텍스트 정규화 (Normalizer 단계)
|
|
132
|
+
*
|
|
133
|
+
* - NFKC: 유니코드 호환 분해 후 합성 (Claude)
|
|
134
|
+
* 예: "fi" → "fi", "Ⅲ" → "III"
|
|
135
|
+
* - Replace: 특정 문자를 다른 문자로 치환 (Gemma3)
|
|
136
|
+
* 예: " " → "▁" (SentencePiece 공백 마커)
|
|
137
|
+
*/
|
|
138
|
+
normalize(text) {
|
|
139
|
+
if (this.normalizerType === 'NFKC') {
|
|
140
|
+
return text.normalize('NFKC');
|
|
141
|
+
}
|
|
142
|
+
if (this.normalizerType === 'Replace' && this.normalizerPattern != null) {
|
|
143
|
+
return text.split(this.normalizerPattern).join(this.normalizerContent);
|
|
144
|
+
}
|
|
145
|
+
return text;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 텍스트를 토큰화 단위로 분리 (PreTokenizer 단계)
|
|
150
|
+
*
|
|
151
|
+
* - ByteLevel (Claude): GPT-2 스타일 정규식으로 단어/숫자/공백 분리
|
|
152
|
+
* - 기타 (Gemma3): 전체 텍스트를 하나의 청크로 처리
|
|
153
|
+
* (SentencePiece는 전체를 한 번에 BPE 처리)
|
|
154
|
+
*/
|
|
155
|
+
preTokenize(text) {
|
|
156
|
+
if (this.preTokenizerType === 'ByteLevel') {
|
|
157
|
+
return text.match(BYTE_LEVEL_REGEX) || [];
|
|
158
|
+
}
|
|
159
|
+
return [text];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 단일 텍스트 조각을 토큰 ID 배열로 인코딩
|
|
164
|
+
*
|
|
165
|
+
* ByteLevel 모드 (Claude):
|
|
166
|
+
* 1. UTF-8 바이트로 변환
|
|
167
|
+
* 2. 각 바이트를 GPT-2 유니코드 문자로 매핑
|
|
168
|
+
* 3. BPE 병합 수행
|
|
169
|
+
* 4. vocab에서 ID 조회
|
|
170
|
+
*
|
|
171
|
+
* SentencePiece 모드 (Gemma3):
|
|
172
|
+
* 1. 문자 단위로 분리
|
|
173
|
+
* 2. BPE 병합 수행
|
|
174
|
+
* 3. vocab에서 ID 조회
|
|
175
|
+
*/
|
|
176
|
+
encodePiece(piece) {
|
|
177
|
+
let symbols;
|
|
178
|
+
|
|
179
|
+
if (this.preTokenizerType === 'ByteLevel') {
|
|
180
|
+
// 텍스트 → UTF-8 바이트 → GPT-2 유니코드 문자 배열
|
|
181
|
+
// 예: "Hello" → [72,101,...] → ["H","e","l","l","o"]
|
|
182
|
+
// 예: " the" → [32,116,...] → ["Ġ","t","h","e"] (공백 0x20 → Ġ)
|
|
183
|
+
const bytes = textEncoder.encode(piece);
|
|
184
|
+
symbols = Array.from(bytes).map(b => BYTE_TO_UNICODE[b]);
|
|
185
|
+
} else {
|
|
186
|
+
// SentencePiece 방식: 유니코드 문자 단위로 분리
|
|
187
|
+
// 예: "▁안녕" → ["▁", "안", "녕"]
|
|
188
|
+
symbols = Array.from(piece);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (symbols.length === 0) return [];
|
|
192
|
+
|
|
193
|
+
// 1개 심볼이면 BPE 불필요, 바로 vocab 조회
|
|
194
|
+
if (symbols.length === 1) {
|
|
195
|
+
const id = this.vocab[symbols[0]];
|
|
196
|
+
return id != null ? [id] : [];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// BPE merge 수행
|
|
200
|
+
const merged = mergeBPE(symbols, this.mergeRanks);
|
|
201
|
+
|
|
202
|
+
// 병합된 각 심볼을 vocab 또는 added_tokens에서 ID로 변환
|
|
203
|
+
const ids = [];
|
|
204
|
+
for (const token of merged) {
|
|
205
|
+
const id = this.vocab[token] ?? this.addedTokens.get(token);
|
|
206
|
+
if (id != null) ids.push(id);
|
|
207
|
+
}
|
|
208
|
+
return ids;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 전체 텍스트를 토큰 ID 배열로 인코딩
|
|
213
|
+
*
|
|
214
|
+
* 파이프라인: normalize → preTokenize → encodePiece → postProcess(BOS)
|
|
215
|
+
*
|
|
216
|
+
* @param {string} text - 인코딩할 텍스트
|
|
217
|
+
* @returns {number[]} 토큰 ID 배열
|
|
218
|
+
*/
|
|
219
|
+
encode(text) {
|
|
220
|
+
text = this.normalize(text);
|
|
221
|
+
const pieces = this.preTokenize(text);
|
|
222
|
+
const result = [];
|
|
223
|
+
|
|
224
|
+
// BOS 토큰이 설정된 경우 맨 앞에 추가 (Gemma3: <bos> id=2)
|
|
225
|
+
if (this.bosTokenId != null) result.push(this.bosTokenId);
|
|
226
|
+
|
|
227
|
+
for (const piece of pieces) {
|
|
228
|
+
result.push(...this.encodePiece(piece));
|
|
229
|
+
}
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* index.js - 통합 토크나이저 API
|
|
3
|
+
*
|
|
4
|
+
* 외부 의존성 없이(commander 제외) OpenAI, Anthropic, Google의
|
|
5
|
+
* 토큰 수를 계산하는 통합 인터페이스입니다.
|
|
6
|
+
*
|
|
7
|
+
* 사용법:
|
|
8
|
+
* import { countTokens } from './index.js';
|
|
9
|
+
* const count = countTokens("Hello world", "openai_o200k");
|
|
10
|
+
*
|
|
11
|
+
* 지원 프로바이더:
|
|
12
|
+
* - openai_o200k : GPT-4.1, o3, o4-mini, GPT-4o (최신)
|
|
13
|
+
* - openai_cl100k : GPT-4-turbo, GPT-4, GPT-3.5-turbo
|
|
14
|
+
* - openai_p50k : Codex, text-davinci (레거시)
|
|
15
|
+
* - anthropic : Claude 4.6 ~ 3 전 계열
|
|
16
|
+
* - google : Gemini 2.5 ~ 1.5, Gemma 전 계열
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
import { fileURLToPath } from 'url';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
import { TiktokenTokenizer } from './tiktoken.js';
|
|
23
|
+
import { HFTokenizer } from './hf-bpe.js';
|
|
24
|
+
|
|
25
|
+
// ES Module에서 __dirname 대체
|
|
26
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const DATA_DIR = path.join(__dirname, 'data');
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 토크나이저 인스턴스 캐시
|
|
31
|
+
* 같은 토크나이저를 반복 사용할 때 데이터를 다시 파싱하지 않도록
|
|
32
|
+
* 한 번 생성한 인스턴스를 재사용합니다.
|
|
33
|
+
*/
|
|
34
|
+
const cache = {};
|
|
35
|
+
|
|
36
|
+
/** data/ 디렉토리에서 JSON 파일을 읽어 파싱 */
|
|
37
|
+
function loadJSON(filename) {
|
|
38
|
+
return JSON.parse(fs.readFileSync(path.join(DATA_DIR, filename), 'utf-8'));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** tiktoken 포맷 토크나이저 싱글턴 로드 (OpenAI용) */
|
|
42
|
+
function getTiktoken(encoding) {
|
|
43
|
+
if (!cache[encoding]) {
|
|
44
|
+
cache[encoding] = new TiktokenTokenizer(loadJSON(`${encoding}.json`));
|
|
45
|
+
}
|
|
46
|
+
return cache[encoding];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** HuggingFace 포맷 토크나이저 싱글턴 로드 (Claude, Gemma3용) */
|
|
50
|
+
function getHF(name) {
|
|
51
|
+
if (!cache[name]) {
|
|
52
|
+
cache[name] = new HFTokenizer(loadJSON(`${name}.json`));
|
|
53
|
+
}
|
|
54
|
+
return cache[name];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 텍스트의 토큰 수를 계산합니다.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} text - 토큰 수를 계산할 텍스트
|
|
61
|
+
* @param {string} provider - 프로바이더 식별자
|
|
62
|
+
* @returns {number} 토큰 수
|
|
63
|
+
* @throws {Error} 알 수 없는 provider일 경우
|
|
64
|
+
*/
|
|
65
|
+
export function countTokens(text, provider) {
|
|
66
|
+
switch (provider) {
|
|
67
|
+
case 'openai_o200k':
|
|
68
|
+
return getTiktoken('o200k_base').encode(text).length;
|
|
69
|
+
case 'openai_cl100k':
|
|
70
|
+
return getTiktoken('cl100k_base').encode(text).length;
|
|
71
|
+
case 'openai_p50k':
|
|
72
|
+
return getTiktoken('p50k_base').encode(text).length;
|
|
73
|
+
case 'anthropic':
|
|
74
|
+
return getHF('claude').encode(text).length;
|
|
75
|
+
case 'google':
|
|
76
|
+
return getHF('gemma3').encode(text).length;
|
|
77
|
+
default:
|
|
78
|
+
throw new Error(`Unknown provider: ${provider}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 사용 가능한 프로바이더 목록 */
|
|
83
|
+
export const providers = [
|
|
84
|
+
'openai_o200k',
|
|
85
|
+
'openai_cl100k',
|
|
86
|
+
'openai_p50k',
|
|
87
|
+
'anthropic',
|
|
88
|
+
'google',
|
|
89
|
+
];
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@eottabom/tokenizer-core",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Offline tokenizer for OpenAI, Anthropic, and Google models.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"tokenizer-core": "./cli.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"*.js",
|
|
15
|
+
"data"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"tokenizer",
|
|
19
|
+
"llm",
|
|
20
|
+
"openai",
|
|
21
|
+
"anthropic",
|
|
22
|
+
"google"
|
|
23
|
+
],
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"commander": "^14.0.3"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/tiktoken.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tiktoken.js - OpenAI tiktoken 포맷 토크나이저
|
|
3
|
+
*
|
|
4
|
+
* OpenAI의 tiktoken은 다음과 같이 동작합니다:
|
|
5
|
+
* 1. 정규식(pat_str)으로 텍스트를 단어/청크 단위로 분리
|
|
6
|
+
* 2. 각 청크를 UTF-8 바이트로 변환
|
|
7
|
+
* 3. 바이트열에 rank 기반 BPE를 적용하여 토큰 ID 생성
|
|
8
|
+
*
|
|
9
|
+
* 데이터 포맷 (encoders/*.json):
|
|
10
|
+
* - pat_str: 사전 분리용 정규식
|
|
11
|
+
* - bpe_ranks: "MARKER OFFSET base64_token1 base64_token2 ..." 형식
|
|
12
|
+
* 각 base64 토큰의 위치(인덱스)가 곧 rank
|
|
13
|
+
* - special_tokens: 특수 토큰 → ID 매핑
|
|
14
|
+
*
|
|
15
|
+
* js-tiktoken (MIT license)의 알고리즘을 참고하여 포팅하였습니다.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { bytePairEncode } from './bpe.js';
|
|
19
|
+
|
|
20
|
+
const textEncoder = new TextEncoder();
|
|
21
|
+
|
|
22
|
+
// ============================================================
|
|
23
|
+
// Base64 디코딩 (외부 의존성 없이 직접 구현)
|
|
24
|
+
// ============================================================
|
|
25
|
+
|
|
26
|
+
const B64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
27
|
+
const b64Lookup = new Uint8Array(128);
|
|
28
|
+
for (let i = 0; i < B64_CHARS.length; i++) {
|
|
29
|
+
b64Lookup[B64_CHARS.charCodeAt(i)] = i;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Base64 문자열을 바이트 배열(Uint8Array)로 디코딩
|
|
34
|
+
* tiktoken의 bpe_ranks에 저장된 토큰은 base64로 인코딩되어 있음
|
|
35
|
+
*
|
|
36
|
+
* 예: "SGVsbG8=" → Uint8Array [72, 101, 108, 108, 111] ("Hello")
|
|
37
|
+
*/
|
|
38
|
+
function base64ToBytes(b64) {
|
|
39
|
+
// 패딩(=) 제거 후 실제 데이터 길이 계산
|
|
40
|
+
let len = b64.length;
|
|
41
|
+
while (b64[len - 1] === '=') len--;
|
|
42
|
+
const byteLen = (len * 3) >> 2; // base64 3/4 비율
|
|
43
|
+
|
|
44
|
+
const bytes = new Uint8Array(byteLen);
|
|
45
|
+
for (let i = 0, j = 0; i < len; i += 4) {
|
|
46
|
+
// 4개의 base64 문자 → 3바이트 변환
|
|
47
|
+
const a = b64Lookup[b64.charCodeAt(i)];
|
|
48
|
+
const b = b64Lookup[b64.charCodeAt(i + 1)];
|
|
49
|
+
const c = b64Lookup[b64.charCodeAt(i + 2)];
|
|
50
|
+
const d = b64Lookup[b64.charCodeAt(i + 3)];
|
|
51
|
+
bytes[j++] = (a << 2) | (b >> 4);
|
|
52
|
+
if (j < byteLen) bytes[j++] = ((b & 0xf) << 4) | (c >> 2);
|
|
53
|
+
if (j < byteLen) bytes[j++] = ((c & 0x3) << 6) | d;
|
|
54
|
+
}
|
|
55
|
+
return bytes;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ============================================================
|
|
59
|
+
// TiktokenTokenizer 클래스
|
|
60
|
+
// ============================================================
|
|
61
|
+
|
|
62
|
+
export class TiktokenTokenizer {
|
|
63
|
+
/**
|
|
64
|
+
* @param {object} data - encoders/*.json에서 로드한 데이터
|
|
65
|
+
* - pat_str: 텍스트 분리용 정규식 패턴
|
|
66
|
+
* - bpe_ranks: rank 데이터 문자열
|
|
67
|
+
* - special_tokens: 특수 토큰 매핑
|
|
68
|
+
*/
|
|
69
|
+
constructor(data) {
|
|
70
|
+
/** @type {Map<string, number>} 바이트열(쉼표 구분) → rank 매핑 */
|
|
71
|
+
this.rankMap = new Map();
|
|
72
|
+
this.patStr = data.pat_str;
|
|
73
|
+
this.specialTokens = data.special_tokens || {};
|
|
74
|
+
|
|
75
|
+
// bpe_ranks 파싱
|
|
76
|
+
// 포맷: "MARKER OFFSET token1 token2 token3 ..."
|
|
77
|
+
// - MARKER: 라인 식별자 (무시)
|
|
78
|
+
// - OFFSET: 이 라인의 첫 토큰에 부여될 rank 번호
|
|
79
|
+
// - token1, token2, ...: base64 인코딩된 바이트 시퀀스
|
|
80
|
+
// → token1의 rank = OFFSET, token2의 rank = OFFSET+1, ...
|
|
81
|
+
const lines = data.bpe_ranks.split('\n').filter(Boolean);
|
|
82
|
+
for (const line of lines) {
|
|
83
|
+
const [, offsetStr, ...tokens] = line.split(' ');
|
|
84
|
+
const offset = parseInt(offsetStr, 10);
|
|
85
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
86
|
+
// base64 디코딩 → 바이트 배열 → 쉼표 구분 문자열로 키 생성
|
|
87
|
+
const bytes = base64ToBytes(tokens[i]);
|
|
88
|
+
this.rankMap.set(Array.from(bytes).join(','), offset + i);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 텍스트를 토큰 ID 배열로 인코딩
|
|
95
|
+
*
|
|
96
|
+
* 과정:
|
|
97
|
+
* 1. pat_str 정규식으로 텍스트를 청크로 분리
|
|
98
|
+
* 예: "Hello world" → ["Hello", " world"]
|
|
99
|
+
* 2. 각 청크를 UTF-8 바이트로 변환
|
|
100
|
+
* 3. 바이트열이 rankMap에 바로 있으면 (= 이미 하나의 토큰) 그대로 사용
|
|
101
|
+
* 4. 없으면 BPE 알고리즘으로 바이트열을 분할/병합하여 토큰 ID 추출
|
|
102
|
+
*
|
|
103
|
+
* @param {string} text - 인코딩할 텍스트
|
|
104
|
+
* @returns {number[]} 토큰 ID 배열
|
|
105
|
+
*/
|
|
106
|
+
encode(text) {
|
|
107
|
+
const regex = new RegExp(this.patStr, 'ug');
|
|
108
|
+
const result = [];
|
|
109
|
+
|
|
110
|
+
for (const match of text.matchAll(regex)) {
|
|
111
|
+
// 매치된 텍스트 조각을 UTF-8 바이트로 변환
|
|
112
|
+
const piece = Array.from(textEncoder.encode(match[0]));
|
|
113
|
+
|
|
114
|
+
// 전체가 하나의 토큰인 경우 빠르게 처리 (최적화)
|
|
115
|
+
const singleToken = this.rankMap.get(piece.join(','));
|
|
116
|
+
if (singleToken != null) {
|
|
117
|
+
result.push(singleToken);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// BPE 병합을 통해 토큰 ID들을 추출
|
|
122
|
+
result.push(...bytePairEncode(piece, this.rankMap));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
}
|