LLM Tokenizer و Attention را از ابتدا در TypeScript بسازید

نوشتن قسمت های اصلی به صورت کد ساده، داده ها و عملیات میانی آنها را آشکار می کند. این آموزش سه مورد از آنها را از ابتدا در TypeScript ایجاد می کند: رمزگذاری جفت بایت (BPE)، جستجوی شباهت کسینوس برداری، و توجه به محصول نقطه مقیاس شده.
چرا ابتدا LLM را در TypeScript ایجاد کنیم؟
محصولاتی مانند ChatGPT و Claude می توانند LLM های اولیه را مانند یک جعبه سیاه جلوه دهند. نوشتن قسمت های اصلی به صورت کد ساده، داده ها و عملیات میانی آنها را آشکار می کند. این آموزش سه مورد از آنها را از ابتدا در TypeScript ایجاد می کند: رمزگذاری جفت بایت (BPE)، جستجوی شباهت کسینوس برداری، و توجه به محصول نقطه مقیاس شده. هر کدام مکانیسم متفاوتی را نشان میدهند: BPE متن را به شناسههای نشانه تبدیل میکند، شباهت برداری نمایشهای عددی را مقایسه میکند، و توجه ترکیب وزنی از بردارهای مقدار را از نتایج کلید پرس و جو محاسبه میکند.
اینها سه نمونه اولیه قابل بازرسی هستند که در گردش های کاری مرتبط با LLM استفاده می شوند. آنها LLM نیستند. ترانسفورماتور توضیح داده شده در Attention Is All You Need نیز بر پیش بینی های آموخته شده، اطلاعات موقعیت، زیرلایه های پیشخور، اتصالات باقیمانده و نرمال سازی متکی است. اجرای توجه رفتار هیچ مدل تولیدی را بازتولید نمی کند.
اینها سه نمونه اولیه قابل بازرسی هستند که در گردش های کاری مرتبط با LLM استفاده می شوند. آنها LLM نیستند.
TypeScript در اینجا مفید است زیرا واسط ها جریان داده ها را مستند می کنند. قرارداد ساده است: توکنایزر ساطع می کند number[]، یک مرجع جاسازی اسباب بازی، هر شناسه را به یک بعد ثابت نگاشت می کند Float64Arrayو توجه دنباله ای منظم از این بردارها را جذب می کند. با این حال، حاشیهنویسیهای نوع، ابعاد آرایه را در زمان اجرا بررسی نمیکنند، بنابراین هر تابع زیر به طور صریح اشکال را بررسی میکند.
نتیجه یک جعبه آموزشی مبتنی بر CPU است. این یک توکنایزر، مدل تعبیهشده آموزشدیده، برنامه RAG یا عملکرد LLM سازگار نیست.
پیش نیازهای پروژه و راه اندازی
این مقاله Node 24.12+ و TypeScript 7.x را هدف قرار می دهد. قبل از تکیه بر اسناد رسمی Node.js و TypeScript، الزامات نسخه و بومی TypeScript را بررسی کنید. جزئیات نسخه ای که در ادامه می آید نشان دهنده زمان نگارش است، بنابراین لطفاً با این منابع رسمی بررسی کنید. TypeScript 7.0 در ژوئیه 2026 ارسال شد. کامپایلر اصلی آن هنوز API کامپایلر برنامهای قدیمیتر را نشان نمیدهد. یکی معمولی tsc گردش کار تحت تأثیر قرار نمی گیرد، اما ابزارهایی که به این API وابسته هستند نیاز به بررسی سازگاری دارند. Node 24.21.0 LTS فعلی است. Node 22.18.0 و نسخه های بعدی به طور پیش فرض اجازه حذف نوع بومی را می دهد، اما این ویژگی در خط 24.x در 24.12.0 پایدار شد. خوانندگانی که در گره 22 می مانند حداقل به 22.18 نیاز دارند.
پیش نیازها: Node.js 24.12+ با npm موجود است. پس از ایجاد فایل های نشان داده شده، قبل از اجرای اسکریپت ها، وابستگی های توسعه را نصب کنید.
“صفر وابستگی” در اینجا معنای دقیقی دارد: بدون کتابخانه ML/ریاضی خارجی و هیچ وابستگی زمان اجرا. این پروژه اعلانهای نوع TypeScript و Node را فقط به عنوان وابستگیهای توسعه برای بررسی نوع نصب میکند. تست ها به جای Vitest از runner داخلی Node استفاده می کنند، بنابراین نیازی به نصب چارچوب آزمایشی ندارید. را test اسکریپت یک glob صریح را برای tests/**/*.test.ts به جای تکیه بر تشخیص پیشفرض که میتواند آزمایشهای پوچ را گزارش کند و همچنان با موفقیت از آن خارج شود. رفتار glob و فایل از دست رفته را در نسخه گره هدف خود تأیید کنید.
تمایز در همه جا اهمیت دارد. انواع نوار برای گره های سفارشی بدون بررسی نوع آنها سختگیرانه tsconfig.json انجام نده node src/demo.ts انجام بررسی های سخت را typecheck اسکریپت باید جداگانه اجرا شود.
{
"name": "llm-primitives-ts",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": { "node": ">=24.12.0" },
"scripts": {
"typecheck": "tsc",
"test": "node --test \"tests/**/*.test.ts\"",
"demo": "node src/demo.ts"
},
"devDependencies": {
"@types/node": "24.0.0",
"typescript": "7.0.0"
}
}
مثال نسخههای وابستگی توسعه را دقیقاً به گونهای پین میکند که هر خواننده زنجیره ابزار یکسانی را حل کند. قبل از اولین استفاده، تأیید کنید که نسخه ثابت TypeScript وجود دارد و ارائه شده است tsc باینری (npm view typescript@7.0.0 bin)، فایل قفل تولید شده را commit کنید و هنگام بررسی مجدد مقاله، پین ها را آگاهانه به روز کنید. به طور پیش فرض، npm فقط زمانی هشدار می دهد که نسخه نصب شده Node با آن مطابقت نداشته باشد engines زمینه اضافه شدن از .npmrc به package.json برای اعمال تأیید:
engine-strict=true
{
"compilerOptions": {
"target": "ES2022",
"module": "nodenext",
"strict": true,
"noEmit": true,
"rewriteRelativeImportExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"types": ["node"]
},
"include": ["src", "tests"]
}
llm-primitives-ts/
├── .npmrc
├── package.json
├── tsconfig.json
├── src/
│ ├── tokenizer/bpe.ts
│ ├── vectors/index.ts
│ ├── attention/attention.ts
│ └── demo.ts
└── tests/
├── bpe.test.ts
├── vectors.test.ts
├── attention.test.ts
└── demo.test.ts
بخش خط لوله زیر لیست کامل هر فایل آزمایشی را فهرست می کند. npm test فقط ویژگی هایی را که آن فایل ها اعلام می کنند بررسی می کند.
از پیکربندی پیشنهادی خود Node استفاده می کند target: "esnext". را ES2022 هدف در اینجا انتخاب عمدی سازگاری است. این باعث نمیشود که Node Native نحوی را که Node قادر به انجام آن نباشد، تبدیل کند.
هر نمونه اجرا می شود .ts فایل های مستقیم برای این کار، باید از واردات نسبی زمان اجرا استفاده کرد .ts پسوند و وارد کردن فقط نوع باید استفاده شود import type. اختلاط این رویکرد با کامپایل به.js استراتژی واردات بی سر و صدا تکرارپذیری را می شکند. Native Node نیز نادیده می گیرد tsconfig.json نام مستعار مسیر از زمان اجرا پشتیبانی نمی کند enum، خصوصیات پارامتر یا .tsx هنگام حذف یک نوع ساده، بنابراین کد زیر از هر سه جلوگیری می کند. مانند جزئیات نسخه بالا، این رفتارهای زمان اجرا را در برابر اسناد رسمی Node.js TypeScript برای نسخه هدف خود تأیید کنید.
بخش 1: ساخت رمزگذار جفت بایت (BPE) Tokenizer.
توکن سازی در واقع چه چیزی را حل می کند
مدل با اعداد صحیح کار می کند نه رشته ها. دیکشنری های سطح کلمه در حال انفجار هستند و هنوز کلمات نامرئی را از دست می دهند. توالی های سطح نماد به اندازه یک فرهنگ لغت فشرده هستند، اما طولانی هستند، و هر عملیات دنباله پایین دست را گران تر می کند. نشانهگذاری زیرکلمهها جایی بین این دو است: قطعات مکرر شناسههای خود را دارند و رشتههای کمیاب به بخشهای کوچکتر تجزیه میشوند. مبادله اندازه فرهنگ لغت در مقابل طول دنباله است.
BPE سطح کاراکتر دارای یک حالت شکست ظریف است. اگر پیکره آموزشی نویسهای را که بعداً در حین رمزگذاری ظاهر میشود از دست بدهد، توکنایزر لزوماً نمیتواند آن را رمزگذاری کند. BPE در سطح بایت از این امر اجتناب می کند. فرهنگ لغت را با تمام مقادیر 256 بایتی اولیه می کند، بنابراین هر متن معتبر UTF-8، از جمله شکلک ها و اسکریپت های دیده نشده، قابل نمایش است. OpenAI آموزشی tiktoken پیاده سازی از این طرح استفاده می کند (منبع آن را برای جزئیات دقیق بررسی کنید)، و این مقاله بر اساس همان طرح است.
الگوریتم BPE، گام به گام
- تقسیم متن به قطعات با یک عبارت منظم قبل از هر چیز دیگری. ادغام فقط اتفاق می افتد در داخل قطعات، هرگز در عرض. اینکه آیا تقسیم جداکنندهها را حفظ میکند یا نه، بسیار مهم است: یک تقسیم فضای سفید که فضاها را رد میکند.
decode(encode(text)) === textغیر ممکن بنابراین، پیادهسازی زیر بررسی میکند که آیا شکلها دوباره به ورودی متصل میشوند یا خیر و در صورت عدم اتصال آنها را پرتاب میکند. پیادهسازی آموزشی OpenAI همان پیش نشانهسازی عبارات منظم را در طول آموزش و کدنویسی اعمال میکند و پیش نشانهسازی را به بخشی از تعریف توکنیزر تبدیل میکند. - سپس هر قسمت را به صورت UTF-8 بایت کدگذاری کنید و هر جفت کاراکتر مجاور را در همه قسمت ها بشمارید.
- حلقه اصلی متداولترین جفت را انتخاب میکند، یک شناسه جدید به آن اختصاص میدهد، وقوع آن را جایگزین میکند، شمارش میکند و تکرار میکند. بعد از آن متوقف می شود
numMergesادغام می شود یا زمانی که هیچ جفت مجاور باقی نمی ماند. برابری باید قطعی باشد. در اینجا پیوندها به کوچکترین شناسه سمت چپ و سپس به کوچکترین شناسه سمت راست می روند. - هر ادغام یک رتبه افزایش می یابد. با شروع 256 شناسه پایه، فرهنگ لغت هر کدام دقیقاً یک نشانه رشد می کند موفق ادغام، بنابراین
numMergesرشد را محدود می کند تا اینکه اندازه فرهنگ لغت را ثابت کند. توکنایزر توکن های خاصی را تعریف نمی کند. آموزشیtiktokenاجرای آنها را نیز از دست می دهد.
کدنویسی از یک جهت مهم با یادگیری متفاوت است. این کار را انجام می دهد نه پرتکرارترین جفت را در ورودی جدید انتخاب کنید. او مکرراً جفت مجاز را با پایین ترین رتبه علمی، ادغام ها را به ترتیبی که یاد گرفته اند پخش می کند.
پیاده سازی BPE در TypeScript
را Vocabulary، MergeRule، و Tokenizer انواع توسط مقالات تعریف می شوند، نه APIهای استاندارد کتابخانه. هر توکن دنباله دقیق بایت های خود را ذخیره می کند. رشته نمایشگر ناکافی است زیرا رمز الحاقی می تواند قطعه ای از یک کاراکتر چند بایتی باشد.
export const BYTE_VOCAB_SIZE = 256;
export const DEFAULT_PATTERN = String.raw` ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+`;
export interface MergeRule {
readonly left: number;
readonly right: number;
readonly rank: number;
readonly id: number;
}
export interface Vocabulary {
readonly idToBytes: readonly Uint8Array[];
}
export interface Tokenizer {
readonly vocab: Vocabulary;
readonly merges: readonly MergeRule[];
readonly pattern: string;
}
export interface PairCount {
readonly left: number;
readonly right: number;
readonly count: number;
}
export function pairKey(left: number, right: number): string {
return `${left},${right}`;
}
export function preTokenize(text: string, pattern: string = DEFAULT_PATTERN): string[] {
const pieces = Array.from(text.matchAll(new RegExp(pattern, 'gu')), (match) => match[0]);
if (pieces.join('') !== text) {
throw new Error('Pre-tokenizer pattern does not cover the input; round trip would be lossy');
}
return pieces;
}
export function getPairFrequencies(
pieces: readonly (readonly number[])[],
): Mapstring, PairCount> {
const counts = new Mapstring, PairCount>();
for (const piece of pieces) {
for (let i = 0; i piece.length - 1; i++) {
const left = piece[i];
const right = piece[i + 1];
const key = pairKey(left, right);
const previous = counts.get(key)?.count ?? 0;
counts.set(key, { left, right, count: previous + 1 });
}
}
return counts;
}
function pickMostFrequent(counts: ReadonlyMapstring, PairCount>): PairCount | undefined {
let best: PairCount | undefined;
for (const candidate of counts.values()) {
if (
best === undefined ||
candidate.count > best.count ||
(candidate.count === best.count &&
(candidate.left best.left ||
(candidate.left === best.left && candidate.right best.right)))
) {
best = candidate;
}
}
return best;
}
export function mergePair(
symbols: readonly number[],
left: number,
right: number,
newId: number,
): number[] {
const out: number[] = [];
let i = 0;
while (i symbols.length) {
if (i symbols.length - 1 && symbols[i] === left && symbols[i + 1] === right) {
out.push(newId);
i += 2;
} else {
out.push(symbols[i]);
i += 1;
}
}
return out;
}
export function mergeMostFrequentPair(
pieces: readonly (readonly number[])[],
newId: number,
): { pieces: number[][]; merged: PairCount } | undefined {
const best = pickMostFrequent(getPairFrequencies(pieces));
if (best === undefined) return undefined;
const { left, right } = best;
return { pieces: pieces.map((p) => mergePair(p, left, right, newId)), merged: best };
}
function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
const out = new Uint8Array(a.length + b.length);
out.set(a, 0);
out.set(b, a.length);
return out;
}
export function trainBPE(corpus: string, numMerges: number): Tokenizer {
if (!Number.isInteger(numMerges) || numMerges 0) {
throw new RangeError(`numMerges must be a non-negative integer, got ${numMerges}`);
}
if (/\p{Cs}/u.test(corpus)) throw new RangeError('Corpus contains unpaired surrogates');
const encoder = new TextEncoder();
let pieces: number[][] = preTokenize(corpus).map((p) => Array.from(encoder.encode(p)));
const idToBytes: Uint8Array[] = [];
for (let byte = 0; byte BYTE_VOCAB_SIZE; byte++) idToBytes.push(Uint8Array.of(byte));
const merges: MergeRule[] = [];
for (let rank = 0; rank numMerges; rank++) {
const id = BYTE_VOCAB_SIZE + rank;
const step = mergeMostFrequentPair(pieces, id);
if (step === undefined) break; pieces = step.pieces;
const { left, right } = step.merged;
merges.push({ left, right, rank, id });
idToBytes.push(concatBytes(idToBytes[left], idToBytes[right]));
}
return { vocab: { idToBytes }, merges, pattern: DEFAULT_PATTERN };
}
رمزگذاری و رمزگشایی متن
طرح کلی است encode(text) آن بالا است encode(tokenizer, text). عبور توکنایزر به وضوح وضعیت آن را به عنوان داده های ساده و پایدار حفظ می کند. getLookup جدول جستجوی ادغام را یک بار برای هر شی توکنایزر می سازد و آن را در حافظه پنهان نگه می دارد، به این معنی که تماس گیرندگان توکن سازها را تغییرناپذیر می دانند. readonly انواع اعلام می کنند. موارد زیر را به همان فایل اضافه کنید:
const lookupCache = new WeakMapTokenizer, Mapstring, MergeRule>>();
function getLookup(tokenizer: Tokenizer): Mapstring, MergeRule> {
let lookup = lookupCache.get(tokenizer);
if (lookup === undefined) {
lookup = new Map();
for (const rule of tokenizer.merges) {
const key = pairKey(rule.left, rule.right);
const existing = lookup.get(key);
if (existing === undefined || rule.rank existing.rank) lookup.set(key, rule);
}
lookupCache.set(tokenizer, lookup);
}
return lookup;
}
export function encode(tokenizer: Tokenizer, text: string): number[] {
if (/\p{Cs}/u.test(text)) throw new RangeError('Input contains unpaired surrogates');
const lookup = getLookup(tokenizer);
const encoder = new TextEncoder();
const ids: number[] = [];
for (const piece of preTokenize(text, tokenizer.pattern)) {
let symbols = Array.from(encoder.encode(piece));
while (symbols.length > 1) {
let best: MergeRule | undefined;
for (let i = 0; i symbols.length - 1; i++) {
const rule = lookup.get(pairKey(symbols[i], symbols[i + 1]));
if (rule !== undefined && (best === undefined || rule.rank best.rank)) best = rule;
}
if (best === undefined) break;
symbols = mergePair(symbols, best.left, best.right, best.id);
}
for (const s of symbols) ids.push(s);
}
return ids;
}
export function decode(tokenizer: Tokenizer, tokenIds: readonly number[]): string {
const table = tokenizer.vocab.idToBytes;
let total = 0;
for (let i = 0; i tokenIds.length; i++) {
const id = tokenIds[i];
if (!Number.isInteger(id) || id 0 || id >= table.length) {
throw new RangeError(`Unknown token ID: ${id} at index ${i}`);
}
total += table[id].length;
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const id of tokenIds) {
bytes.set(table[id], offset);
offset += table[id].length;
}
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
}
آموزش از "low lower lowest" با سه ادغام به شرح زیر است. قبل از توکنسازی برمیگردد "low"، " lower"، " lowest". زوج ها l+o و o+w یک تساوی در سه تکرار و تای شکن انتخاب می کند l+o (شناسه 256). بعدی lo+w (257). در نهایت " "+low ارتباطات با low+e در دو تکرار و برنده شدن در ID کوچکتر سمت چپ (258). در حال اجرا encode و decode در چندین ورودی تولید می کند:
"lowest low" -> [257, 101, 115, 116, 258] -> "lowest low"
"héllo 👋" -> [104, 195, 169, 108, 256, 32, 240, 159, 145, 139] -> "héllo 👋"
vocabulary size: 259 (256 bytes + 3 merges)
خط دوم نشان می دهد که چرا این آموزش از BPE سطح بایت استفاده می کند. é و 👋 هرگز در پوسته ظاهر نمی شوند، بنابراین آنها به بایت های خام UTF-8 خود برمی گردند و همچنان به جلو و عقب می روند.
خط مشی خطا عمدی است. شناسههای ناشناس پرتاب میشوند و پیام موقعیت آنها را اعلام میکند. UTF-8 نادرست نیز پرتاب میکند، و این میتواند با توالیهای دلخواه از شناسهها، مانند شناسههای نامحدود تولید شده توسط الگویی که یک کاراکتر را به نصف برش میدهد، رخ دهد. encode ورودیهای حاوی جایگزینهای جفتنشده در جلو را رد میکند زیرا UTF-8 نمیتواند آن را بدون ضرر نشان دهد. decode یک کاراکتر خط بایت اصلی (U+FEFF) را به جای حذف آن حفظ می کند. برنامه هایی که خروجی جزئی را پخش می کنند به خط مشی متفاوتی نیاز دارند.
مجموعه آزمایشی باید جفتهای تکرار شونده، فرکانسهای محدود، ورودی خالی، برگهها و خطوط جدید، فضاهای پیشرو و انتهایی، U+FEFF پیشرو، تکتنهای بسیار طولانی، شکلکها و یونیکد را پوشش دهد که در آموزش وجود ندارند. هر گونه عادی سازی یا تغییر توکنایزر مجدد از دست رفته، ضمانت رفت و برگشت را باطل می کند، بنابراین توکنیزر صرفه جویی می کند pattern باشه merges. الگوی ثابت که برخی از ورودیها را پوشش نمیدهد، اکنون بهجای حذف بیصدا متن، پرتاب میشود. چون preTokenize مدل ماندگار را به a کامپایل می کند RegExp، فایل های نشانه ساز را فقط به عنوان ورودی قابل اعتماد در نظر می گیرد. مدل از یک مخزن نامعتبر می تواند باعث عقبگرد فاجعه آمیز یا تقسیم ناخواسته شود. هزینه رمزگذاری نیز در بدترین حالت با طول یک تکه به صورت درجه دوم افزایش می یابد، بنابراین قبل از رمزگذاری متن غیرقابل اعتماد، حداکثر طول ورودی را تنظیم کنید. این مقاله یک محدودیت برای همه پیشنهاد نمی کند. یکی از بدترین زمانهای رمزگذاری و مصرف حافظه را که در استقرار خود اندازهگیری میکنید، انتخاب کنید. BPE سطح بایت به تنهایی این GPT-2 یا را نمی سازد tiktoken سازگار سازگاری همچنین مستلزم تطبیق پیش توکنسازی، رتبهبندی و مدیریت توکنهای خاص است.
قسمت 2: نمایش معنا به صورت بردار
از توکنها تا جاسازیها (مفهومی)
در یک مدل، یک جدول جاسازی هر شناسه نشانه را به یک بردار علمی نگاشت می کند. این آموزش جایگزین بردارهای ساختگی قطعی می شود. این جایگزینی یک پیامد مستقیم دارد. رتبه های تشابه کسینوس موجود بردارهای جهت; معنی ندارد بردارهای تصادفی یا دستی می توانند محاسبات و نمایه را انجام دهند، اما نتایج جستجوی آنها چیزی در مورد بازیابی معنایی نمی گوید. استخراج مبتنی بر جاسازی، همانطور که توسط OpenAI Embedding Guide توضیح داده شده است، به بردارهای تولید شده توسط یک مدل جاسازی بستگی دارد.
رتبه های تشابه کسینوس موجود بردارهای جهت; معنی ندارد
نمونه هایی از وکتورهای ذخیره Float64Array مقادیری که از 8 بایت در هر عنصر استفاده می کنند. انتخاب برای محاسبات شفاف با عرض ثابت است. این یک ادعای توان عملیاتی نیست. هیچ چیز در مشخصات آرایه تایپ شده مزیت عملکردی ایجاد نمی کند و یک سیستم واقعی باید دقت، حافظه و مقیاس پذیری را بسنجید.
اندازه گیری تشابه: تابع تشابه کسینوس
محصول نقطه ای، محصولات را بر اساس عناصر جمع می کند. قدر جذر حاصل ضرب نقطه ای یک بردار با خودش است. شباهت کسینوس حاصل ضرب نقطه ای حاصلضرب دو کمیت را تقسیم می کند و مقداری از -1 (مقابل) تا 0 (متعامد) به 1 (همان جهت) می دهد.
اجزای مستقیم مربع ممکن است سرریز شوند Infinity برای مقادیر بسیار بزرگ، یا تا 0 برای مقادیر بسیار کوچک، که تولید خواهد کرد NaN بردارهای معتبر را ارزیابی یا به اشتباه رد می کند. بنابراین، پیادهسازی هر بردار را قبل از مربع کردن، با بزرگترین مؤلفه مطلق آن مقیاس میکند، هر بردار را قبل از گرفتن حاصل ضرب نقطهای نرمال میکند و نتیجه را گرد میکند. [-1, 1] برای جذب گرد شدن
وقتی هر یک از مقادیر صفر باشد، فرمول تعریف نشده است. بازگشت NaN یا علامت گذاری بی سر و صدا خطاها را پنهان می کند تا پیاده سازی پرتاب شود. pgvector به طور مشابه بردارهای صفر را از شاخص های فاصله کسینوس حذف می کند (این را در مستندات pgvector نسخه خود تأیید کنید).
function assertFinite(v: Float64Array, name: string): void {
for (let i = 0; i v.length; i++) {
if (!Number.isFinite(v[i])) throw new RangeError(`${name}[${i}] is not finite`);
}
}
function assertSameDimension(a: Float64Array, b: Float64Array): void {
if (a.length !== b.length) {
throw new RangeError(`Dimension mismatch: ${a.length} vs ${b.length}`);
}
}
export function dotProduct(a: Float64Array, b: Float64Array): number {
assertSameDimension(a, b);
let sum = 0;
for (let i = 0; i a.length; i++) sum += a[i] * b[i];
return sum;
}
export function magnitude(v: Float64Array): number {
let scale = 0;
for (let i = 0; i v.length; i++) scale = Math.max(scale, Math.abs(v[i]));
if (scale === 0 || !Number.isFinite(scale)) return scale;
let sum = 0;
for (let i = 0; i v.length; i++) {
const x = v[i] / scale;
sum += x * x;
}
return scale * Math.sqrt(sum);
}
export function cosineSimilarity(a: Float64Array, b: Float64Array): number {
assertSameDimension(a, b);
assertFinite(a, 'a');
assertFinite(b, 'b');
const magA = magnitude(a);
const magB = magnitude(b);
if (magA === 0 || magB === 0) {
throw new RangeError('Cosine similarity is undefined for a zero-magnitude vector');
}
if (!Number.isFinite(magA) || !Number.isFinite(magB)) {
throw new RangeError('Vector magnitude overflows float64');
}
let dot = 0;
for (let i = 0; i a.length; i++) dot += (a[i] / magA) * (b[i] / magB);
return Math.min(1, Math.max(-1, dot));
}
ساخت حداقل شاخص برداری
شاخص اعتبار ابعاد و topK و شناسه و نتیجه هر نتیجه را برمی گرداند. پیوندهای نتیجه را بر اساس شناسه به ترتیب صعودی واحدهای کد UTF-16 می شکند، بنابراین شناسه رشته های عددی به صورت عددی مرتب نمی شوند ("10" مرتب سازی قبل "9"). add() ورودی را یک بار تأیید می کند و یک کپی عادی را ذخیره می کند تا تماس گیرنده ای که بعداً نسخه اصلی را جهش می دهد نتواند داده های از پیش فهرست شده را خراب کند. search() یکبار پرس و جو را تایید و عادی می کند، حتی زمانی که نمایه خالی است، و سپس هر رکورد را با یک ضرب نقطه ارزیابی می کند. کلاس از خصوصی استفاده می کند # فیلدها چون جاوا اسکریپت ساده هستند و از نوع stripping زنده می مانند. ویژگی های پارامتر نمی خواهد.
export interface SearchResult {
readonly id: string;
readonly score: number;
}
function toUnit(v: Float64Array, name: string): Float64Array {
assertFinite(v, name);
const mag = magnitude(v);
if (mag === 0) throw new RangeError(`Zero vector rejected: ${name}`);
if (!Number.isFinite(mag)) throw new RangeError(`Magnitude overflows float64: ${name}`);
return Float64Array.from(v, (x) => x / mag);
}
export class VectorIndex {
readonly dimension: number;
readonly #entries: { id: string; vector: Float64Array }[] = [];
readonly #ids = new Setstring>();
constructor(dimension: number) {
if (!Number.isInteger(dimension) || dimension 1) {
throw new RangeError(`dimension must be a positive integer, got ${dimension}`);
}
this.dimension = dimension;
}
get size(): number {
return this.#entries.length;
}
add(id: string, vector: Float64Array): void {
if (vector.length !== this.dimension) {
throw new RangeError(`Expected dimension ${this.dimension}, got ${vector.length}`);
}
if (this.#ids.has(id)) throw new Error(`Duplicate id: ${id}`);
const unit = toUnit(vector, id);
this.#ids.add(id);
this.#entries.push({ id, vector: unit });
}
search(query: Float64Array, topK: number): SearchResult[] {
if (!Number.isInteger(topK) || topK 1) {
throw new RangeError(`topK must be a positive integer, got ${topK}`);
}
if (query.length !== this.dimension) {
throw new RangeError(`Expected dimension ${this.dimension}, got ${query.length}`);
}
const q = toUnit(query, 'query');
const scored = this.#entries.map((e) => ({
id: e.id,
score: Math.min(1, Math.max(-1, dotProduct(q, e.vector))),
}));
scored.sort((x, y) => y.score - x.score || (x.id y.id ? -1 : x.id > y.id ? 1 : 0));
return scored.slice(0, topK);
}
}
این اسکن هر بردار را ارزیابی میکند و اولین نتیجه k کسینوس را پس از شناسایی تایبرک مستند برمیگرداند. وقتی چندین بردار در نتیجه k با هم برابر باشند، بیش از یک مجموعه معتبر ریاضی از بالاترین k وجود دارد. قانون شناسه صعودی به طور قطعی یکی از آنها را انتخاب می کند. هزینه امتیاز دهی O(Nd)؛ این پیاده سازی سپس تمام N نتایج را مرتب می کند و O(N log N) را اضافه می کند. شاخصهای تولید نزدیکترین همسایه، مانند HNSW و IVFFlat، از برخی فراخوانیها صرف نظر میکنند و حافظه و تلاش بیشتری را مصرف میکنند، بنابراین پرس و جو نیازی به ارزیابی هر بردار ذخیرهشده ندارد. قبل از انتخاب یکی، تاخیر پرس و جو را با حجم کاری خود مقایسه کنید. مستندات pgvector خاطرنشان می کند که به طور پیش فرض یک جستجوی دقیق انجام می دهد تا زمانی که یک نمایه تقریبی اضافه کنید (مستندات را برای نسخه pgvector خود بررسی کنید). این مقاله محدودیت اندازه مجموعه جهانی را ارائه نمی دهد. هنگامی که تأخیر پرس و جو اندازه گیری شده در N و d مورد انتظار شما متناسب با بودجه شما باشد، یا زمانی که دقت مهمتر از تأخیر باشد، اسکن خطی یک انتخاب معقول است.
این شاخص به یک بهینه سازی متکی است و فقط به این دلیل کار می کند add() و search() عادی سازی بردارها هنگامی که بردارها دارای طول واحد هستند، حاصلضرب نقطهای رتبهبندی مشابهی با شباهت کسینوس ایجاد میکند، بنابراین هر دو روش قبل از امتیازدهی نرمال میشوند. OpenAI این را برای خروجی های تعبیه شده عادی خود مستند می کند. مدل های دقیق موجود در راهنمای تعبیه OpenAI را تأیید کنید. میانبر برای بردارهای اسباب بازی تصادفی که هیچ کس ابتدا آنها را عادی نکرده است، اعمال نمی شود.
این شاخص مربوط به RAG است، اما فقط نزدیکترین رتبه بندی برداری را اعمال می کند. اسناد را تقسیم نمیکند، جاسازیها را ایجاد نمیکند، ابردادهها را فیلتر نمیکند، یا دستورات را جمعآوری نمیکند. آزمون ها باید بردارهای متعامد، متضاد و مساوی را پوشش دهند. طول های نامتناسب؛ بردارهای صفر و نامتناهی؛ مقادیر بسیار زیاد و بسیار کم؛ شاخص های خالی؛ topK > count; و روابط قطعی
بخش 3: توجه به خود: محاسبه نمایش نشانه های حساس به زمینه
عنوان طرح توجه را به عنوان «مکانیزم پشت فهم» چارچوب میدهد. این معادله از عبارت باریک تری پشتیبانی می کند: ترکیب وزنی بردارهای مقدار را محاسبه می کند. عنوان بالا منعکس کننده این ادعای محدودتر است.
این معادله از عبارت باریک تری پشتیبانی می کند: ترکیب وزنی بردارهای مقدار را محاسبه می کند.
چرا توجه یک پیشرفت بود
توجه به هر موقعیت در یک دنباله اجازه می دهد تا ارتباط خود را با هر موقعیت دیگر ارزیابی کند، سپس خروجی آن را به عنوان میانگین وزنی مقادیر آنها بسازد. بنابراین، نمایش هر توکن به زمینه آن بستگی دارد.
فرمول توجه مقیاس بندی محصول نقطه ای
عملیات از “توجه همه شما نیاز است” است softmax(QKᵀ / √dₖ)V. الگوریتم softmax را از طریق اعمال می کند هر خط از پرس و جو از نتایج کلیدی فرم ها عبارتند از:
QاستL×dₖ: یک خط پرس و جو در هر موقعیت شروع.KاستS×dₖ: یک ردیف کلید در هر موقعیت بازدید شده.VاستS×dᵥ: یک ردیف با مقدار در موقعیت فعلی.
نتیجه این است L×dᵥ. در مراقبت از خود، L = S رایج است.
تحت فرضیات سادهسازی معمولی مبنی بر اینکه پرس و جو و مؤلفههای کلیدی مستقل هستند، با میانگین صفر و در مقیاس مشابه هستند، واریانس حاصلضرب نقطهای با افزایش مییابد. dₖ; تقسیم از √dₖ این رشد را کنترل می کند. بدون 1/√dₖ فاکتور، بزرگی نتایج همچنان در حال رشد است dₖ و softmax را به حالت اشباع فشار دهید که گرادیان های آن را کاهش می دهد. سپس سافت مکس هر ردیف از امتیازها را به وزن های غیرمنفی تبدیل می کند که مجموع آنها به یک می رسد.
در یک ترانسفورماتور واقعی، Q، K و V از آن می آیند پیش بینی های آموخته شده در ورودی راه اندازی Q = K = V = embeddings ساده سازی عمدی این نمایش است.
اجرای عملیات ماتریس از ابتدا
طرح کلی مشخص می کند number[][]. این منافات دارد Float64Array محدودیت، بنابراین ماتریس ها اینجا هستند readonly Float64Array[] (یک آرایه تایپ شده در هر خط). اینها رابط های مقاله پیشنهادی هستند، نه APIهای استاندارد. Die helpers قالب های شکسته و اشکال ناسازگار را رد می کنند.
Softmax از یک فرم عددی پایدار استفاده می کند exp(xᵢ − max) / Σ exp(xⱼ − max). دستوری که کاملاً هست -Infinity (کاملاً مبدل) پرتاب می کند. شماره 103749 PyTorch نشان میدهد که چگونه این مورد غیر از این است NaN.
export type Matrix = readonly Float64Array[];
export function shape(m: Matrix): [number, number] {
if (m.length === 0 || m[0].length === 0) throw new RangeError('Matrix must be non-empty');
const cols = m[0].length;
for (const row of m) {
if (row.length !== cols) throw new RangeError('Ragged matrix: rows differ in length');
}
return [m.length, cols];
}
export function matmul(a: Matrix, b: Matrix): Float64Array[] {
const [n, k] = shape(a);
const [kb, m] = shape(b);
if (k !== kb) throw new RangeError(`matmul: ${n}x${k} cannot multiply ${kb}x${m}`);
const out = Array.from({ length: n }, () => new Float64Array(m));
for (let i = 0; i n; i++) {
for (let p = 0; p k; p++) {
const aip = a[i][p];
for (let j = 0; j m; j++) out[i][j] += aip * b[p][j];
}
}
return out;
}
export function transpose(matrix: Matrix): Float64Array[] {
const [rows, cols] = shape(matrix);
const out = Array.from({ length: cols }, () => new Float64Array(rows));
for (let i = 0; i rows; i++) {
for (let j = 0; j cols; j++) out[j][i] = matrix[i][j];
}
return out;
}
export function softmax(row: Float64Array): Float64Array {
if (row.length === 0) throw new RangeError('softmax: empty row');
let max = -Infinity;
for (const x of row) {
if (Number.isNaN(x) || x === Infinity) throw new RangeError('softmax: invalid score');
if (x > max) max = x;
}
if (max === -Infinity) throw new RangeError('softmax: row is fully masked');
const out = new Float64Array(row.length);
let sum = 0;
for (let i = 0; i row.length; i++) {
out[i] = Math.exp(row[i] - max);
sum += out[i];
}
for (let i = 0; i out.length; i++) out[i] /= sum;
return out;
}
مونتاژ: کارکرد توجه
تابع توجه را برمی گرداند وزنه ها همراه با خروجی فقط نگاه کردن به خروجی گام میانگین وزنی را که بخش مرکزی مکانیسم است پنهان می کند. ماسک علّی اختیاری نتایج کلیدی آینده را بر روی آن تنظیم می کند -Infinity قبل از سافت مکس ماسک هرگز مورب را لمس نمی کند، بنابراین هیچ ردیفی نمی تواند به طور کامل ماسک شود.
export interface AttentionOptions {
readonly causal?: boolean;
}
export interface AttentionResult {
readonly weights: Float64Array[];
readonly output: Float64Array[];
}
export function scaledDotProductAttention(
Q: Matrix,
K: Matrix,
V: Matrix,
options: AttentionOptions = {},
): AttentionResult {
const [L, dK] = shape(Q);
const [S, dKk] = shape(K);
const [Sv] = shape(V);
if (dK !== dKk) throw new RangeError(`Q and K key dims differ: ${dK} vs ${dKk}`);
if (S !== Sv) throw new RangeError(`K and V lengths differ: ${S} vs ${Sv}`);
if (options.causal === true && L !== S) {
throw new RangeError('Causal mask requires L === S');
}
const scores = matmul(Q, transpose(K));
const scale = 1 / Math.sqrt(dK);
for (let i = 0; i L; i++) {
for (let j = 0; j S; j++) {
scores[i][j] = options.causal === true && j > i ? -Infinity : scores[i][j] * scale;
}
}
const weights = scores.map((row) => softmax(row));
const output = matmul(weights, V);
return { weights, output };
}
یک نمونه کار سه نشانه دریافت کنید Q = K = V = I₃، ماتریس هویت 3 × 3 و بدون ماسک. QKᵀ هویت است. پس از مقیاس بندی، هر نتیجه مورب است 1/√3 ≈ 0.5774 و هر نتیجه ای خارج از مورب است 0. سافت مکس روشن است [0.5774, 0, 0] تقریبا است [0.4711, 0.2645, 0.2645]، بنابراین اوزان عبارتند از:
weights (unmasked) output = weights x I
[0.4711, 0.2645, 0.2645] [0.4711, 0.2645, 0.2645]
[0.2645, 0.4711, 0.2645] [0.2645, 0.4711, 0.2645]
[0.2645, 0.2645, 0.4711] [0.2645, 0.2645, 0.4711]
weights (causal: true)
[1.0000, 0.0000, 0.0000]
[0.3595, 0.6405, 0.0000]
[0.2645, 0.2645, 0.4711]
چون V هویت است، هر ردیف خروجی برابر با ردیف وزن خود است. این مثال مقادیر مورد انتظار خاصی را بدون فرض “درک” آموزش دیده ارائه می دهد.
نسخه ماسک شده یک محدودیت مهم را برجسته می کند. توجه بدون نقاب دو طرفه است: نشانه 0 از نشانه های 1 و 2 می گیرد. پیش بینی به سبک رمزگشای نشانه بعدی به ماسک علّی نیاز دارد، بنابراین نسخه بدون نقاب توجه نشانه بعدی LLM نیست. خود توجه نیز مفهومی از نظم نمادین ندارد. ترانسفورماتور اصلی اطلاعات موقعیت را برای ارائه آن اضافه می کند.
هزینه محدودیت دیگر است. ماتریس حاصل به صورت درجه دوم در توجه به خود رشد می کند. ورودی آموزش را محدود نگه دارید، و قبل از پذیرش ورودی غیرقابل اعتماد، یک محدودیت طول دنباله را بر اساس حافظه ماتریس نتیجه و تأخیری که در اجرای خود اندازهگیری میکنید، تعیین کنید. همین احتیاط در مورد دیگر موارد اولیه نیز اعمال می شود: آموزش BPE هر جفت را پس از هر ادغام می شمارد و شاخص برداری هر رکورد پرس و جو ذخیره شده را ارزیابی و مرتب می کند. چارچوبهای تولیدی مانند PyTorch میتوانند هستههای توجه بهینهشده را به جای تحقق این توالی از عملیات ماتریس ساده انتخاب کنند.
اتصال اولیه ها در یک خط لوله کوچک
پس از جاسازی جستجو، خط لوله به دو قسمت تقسیم می شود دو شاخه. جستجوی برداری، بردارهای ذخیره شده را در برابر یک پرس و جو رتبه بندی می کند. خودآگاهی یک توالی منظم را زمینه سازی می کند. هیچ کدام به دیگری غذا نمی دهد، بنابراین رنگ کردن آنها به عنوان یک زنجیره نقش آنها را نادرست نشان می دهد.
جدول جستجو هر شناسه فرهنگ لغت، از جمله ادغام های آموخته شده را پوشش می دهد. جدولی با اندازه تنها 256 بایت پایه به زودی از کار می افتد encode شناسه پخش 256 یا بالاتر. نمایش بردارها را به طور قطعی تولید می کند. پیادهسازیهای کاملاً تکرارپذیر علاوه بر این به زنجیره ثابتی از ابزارها و وابستگیها نیاز دارند.
import { trainBPE, encode, decode } from './tokenizer/bpe.ts';
import { VectorIndex } from './vectors/index.ts';
import { scaledDotProductAttention } from './attention/attention.ts';
const DIM = 4;
const tokenizer = trainBPE('low lower lowest', 3);
const ids = encode(tokenizer, 'lowest low');
if (ids.length === 0) throw new Error('encode produced no tokens; nothing to search or attend over');
const table = tokenizer.vocab.idToBytes.map((_, id) =>
Float64Array.from({ length: DIM }, (_, d) => Math.sin((id + 1) * (d + 1))));
const sequence = ids.map((id) => table[id]);
const index = new VectorIndex(DIM);
table.forEach((vector, id) => index.add(String(id), vector));
console.log('exact toy cosine neighbours:', index.search(sequence[0], 3));
const { weights, output } = scaledDotProductAttention(sequence, sequence, sequence);
console.log('token ids:', ids, '| round trip:', decode(tokenizer, ids));
console.log('attention weights:', weights);
console.log('untrained attention outputs:', output);
شاخص های شاخه A نشانه، بخشی از یک سند نیست. همسایه بالایی خود نشانه پرس و جو با نتیجه 1 (تا گرد کردن نقطه شناور) است. برچسب های خروجی تفاوت بین دو شاخه را واضح می کند.
npm test فقط آنچه را که فایل های آزمایشی ادعا می کنند تأیید می کند. فایلهای زیر ویژگیهای نشانهساز، برداری، و توجه مورد بحث در بالا، بهعلاوه یک اجرای نمایشی سرتاسر را پوشش میدهند.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { trainBPE, encode, decode } from '../src/tokenizer/bpe.ts';
const tok = trainBPE('low lower lowest', 3);
test('matches the documented trace', () => {
assert.deepEqual(encode(tok, 'lowest low'), [257, 101, 115, 116, 258]);
assert.equal(tok.vocab.idToBytes.length, 259);
});
test('round-trips a leading BOM, whitespace, and unseen Unicode', () => {
for (const s of ['\uFEFFabc', '', ' \t
low ', 'héllo 👋', '日本語']) {
assert.equal(decode(tok, encode(tok, s)), s);
}
});
test('long single piece does not overflow the stack', () => {
assert.equal(encode(tok, 'x'.repeat(500_000)).length, 500_000);
});
test('rejects lone surrogates and unknown IDs', () => {
assert.throws(() => encode(tok, 'a\uD800b'), RangeError);
assert.throws(() => decode(tok, [9999]), RangeError);
});
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { cosineSimilarity, VectorIndex } from '../src/vectors/index.ts';
test('extreme magnitudes stay finite and correct', () => {
assert.equal(cosineSimilarity(Float64Array.of(1e200), Float64Array.of(1e200)), 1);
const idx = new VectorIndex(1);
assert.doesNotThrow(() => idx.add('tiny', Float64Array.of(1e-200)));
});
test('deterministic ties and zero query rejected on empty index', () => {
const idx = new VectorIndex(2);
idx.add('b', Float64Array.of(1, 0));
idx.add('a', Float64Array.of(2, 0));
assert.deepEqual(idx.search(Float64Array.of(1, 0), 2).map((r) => r.id), ['a', 'b']);
assert.throws(() => new VectorIndex(2).search(Float64Array.of(0, 0), 1), RangeError);
});
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { scaledDotProductAttention } from '../src/attention/attention.ts';
const I3 = [Float64Array.of(1, 0, 0), Float64Array.of(0, 1, 0), Float64Array.of(0, 0, 1)];
test('identity example and causal row values', () => {
const { weights } = scaledDotProductAttention(I3, I3, I3);
assert.ok(Math.abs(weights[0][0] - 0.4711) 1e-4);
for (const row of weights) assert.ok(Math.abs(row.reduce((s, x) => s + x, 0) - 1) 1e-12);
const causal = scaledDotProductAttention(I3, I3, I3, { causal: true }).weights;
assert.equal(causal[0][1], 0);
assert.ok(Math.abs(causal[1][1] - 0.6405) 1e-4);
});
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
test('demo runs end-to-end', () => {
const r = spawnSync(process.execPath, ['src/demo.ts'], { encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /round trip: lowest low/);
assert.match(r.stdout, /id: '257'/);
});
با نصب فایلهای آزمایشی، وابستگیهای ثابت را نصب کنید، سپس بررسی نوع، تستها و دمو را اجرا کنید:
node --version
npm --version
npm install
npm ci
npm run typecheck
npm test
npm run demo
npm test فرار می کند node --test تمام tests/**/*.test.ts، و npm run typecheck فرار می کند tsc به طور جداگانه (ص noEmit مستقر شدن tsconfig.json) ، زیرا اجرای تست ها آنها را تایپ نمی کند. به عنوان یک بررسی سریع سلامت عقل در سفر رفت و برگشت BOM، اجرا کنید:
node --input-type=module -e "import {trainBPE,encode,decode} from './src/tokenizer/bpe.ts'; const t=trainBPE('low lower lowest',3); console.log(decode(t,encode(t,'\uFEFFlow'))==='\uFEFFlow')"
نتیجه مورد انتظار: true.
آنچه در مورد LLM واقعی به شما می آموزد (و چه چیزی نمی دهد).
این پروژه توجه تک سر را بدون جاسازی های آموخته شده یا پیش بینی های Q/K/V آموخته شده اجرا می کند. ماسک اختیاری است و در نسخه نمایشی خاموش می شود. این کد همچنین آموزش (انتشار پس و حلقه آموزشی) را بهطور کامل همراه با اطلاعات موقعیتی، توجه چند سر، بقیه بلوک ترانسفورماتور (زیرلایههای پیشخور، اتصالات باقیمانده، عادیسازی) و موازیسازی GPU کنار میگذارد. API توجه PyTorch ماسک کردن، حذف کردن، اندازههای دستهای و هدر و انتخاب هسته را نشان میدهد که هیچ کدام در اینجا ظاهر نمیشوند.
وزن های رفت و برگشت خالص یا توجه که مجموع ردیف های آن ها برابر با یک است، به نقاب علّی احترام می گذارد و با مثال پردازش شده مطابقت دارد، نشان می دهد که پیاده سازی به درستی عمل می کند. کیفیت مدل، کیفیت بازیابی یا سازگاری با یک مدل خاص را نشان نمی دهد.
یک پیشرفت معقول از اینجا عبارت است از: پوشاندن علّی پیشفرض، سپس پیشبینیهای آموختهشده و اطلاعات موقعیتی، سپس توجه چند سر، سپس بلوک کامل ترانسفورماتور.
یافته های کلیدی
- یک نشانهساز BPE در سطح بایت اسباببازی دور متن خوشفرمشدهای که خودش رمزگذاری کرده (شامل یک U+FEFF) حلقه میزند، مشروط بر اینکه پیش نشانهساز دائمی آن بدون تغییر باقی بماند و همچنان واجد شرایط ادغام شود. جایگزینهای جفتنشده و الگوهایی را که ورودی را پوشش نمیدهند، رد میکند و توالیهای شناسه رمز تصادفی همچنان میتوانند در رمزگشایی UTF-8 شکست بخورند.
- جستجوی یک بردار در اینجا به معنای اسکن کامل برای تشابه کسینوس در حافظه بردارهای ارائه شده است. در برابر سرریز و زیر جریان محافظت می کند و اتصالات را به طور قطعی توسط ID قطع می کند.
- برای جلب توجه، پیادهسازی محصول نقطهای با مقیاس تک سر با بررسی شکل، نرمافزار قوی، و پوشش علّی اختیاری دریافت میکنید.
این پروژه از هیچ کتابخانه خارجی ML/Math استفاده نمی کند و هیچ وابستگی زمان اجرا ندارد. هر یک از آنها را می توان به تنهایی آزمایش کرد و هیچ یک از آنها توانایی های زبانی آموزش دیده ای ندارند.
Source link



