UNPKG

chonkie

Version:

πŸ¦› CHONK your texts in TS with Chonkie!✨The no-nonsense lightweight and efficient chunking library.

458 lines β€’ 21.6 kB
"use strict"; /** Module containing RecursiveChunker class. */ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.RecursiveChunker = void 0; const tokenizer_1 = require("../tokenizer"); const recursive_1 = require("../types/recursive"); const base_1 = require("./base"); const hub_1 = require("../utils/hub"); /** * Recursively chunk text using a set of rules. * * This class extends the BaseChunker class and implements the chunk method. * It provides a flexible way to chunk text based on custom rules, including * delimiters, whitespace, and token-based chunking. * * @extends BaseChunker * @property {number} chunkSize - The maximum number of tokens per chunk. * @property {number} minCharactersPerChunk - The minimum number of characters per chunk. * @property {RecursiveRules} rules - The rules that define how text should be recursively chunked. * @property {string} sep - The separator string used for internal splitting (usually "βœ„"). * * @method chunk - Recursively chunk a single text into chunks or strings. * @method chunkBatch - Recursively chunk a batch of texts. * @method toString - Returns a string representation of the RecursiveChunker instance. * @method call - Call the chunker with a single string or an array of strings. (see callable signatures) * * @static * @method create * @memberof RecursiveChunker * @param {RecursiveChunkerOptions} [options] - Configuration options for the RecursiveChunker. * @returns {Promise<RecursiveChunker>} A Promise that resolves to a RecursiveChunker instance. * * @example * const chunker = await RecursiveChunker.create({ chunkSize: 256 }); * const chunks = await chunker("Some text to chunk"); * const batchChunks = await chunker(["Text 1", "Text 2"]); */ class RecursiveChunker extends base_1.BaseChunker { /** * Private constructor. Use `RecursiveChunker.create()` to instantiate. */ constructor(tokenizer, chunkSize, rules, minCharactersPerChunk) { super(tokenizer); this._CHARS_PER_TOKEN = 6.5; if (chunkSize <= 0) { throw new Error("chunkSize must be greater than 0"); } if (minCharactersPerChunk <= 0) { throw new Error("minCharactersPerChunk must be greater than 0"); } if (!(rules instanceof recursive_1.RecursiveRules)) { throw new Error("rules must be a RecursiveRules object"); } this.chunkSize = chunkSize; this.minCharactersPerChunk = minCharactersPerChunk; this.rules = rules; this.sep = "βœ„"; } /** * Creates and initializes a directly callable RecursiveChunker instance. * * This static factory method constructs a RecursiveChunker with the provided options and returns a callable function object. * The returned instance can be used as both a function (to chunk text(s)) and as an object (with all RecursiveChunker methods and properties). * * @param {RecursiveChunkerOptions} [options] - Configuration options for the chunker. All options are optional: * @param {string|Tokenizer} [options.tokenizer="Xenova/gpt2"] - Tokenizer to use for text processing. Can be a string identifier (e.g., "Xenova/gpt2") or a Tokenizer instance. If a string is provided, Tokenizer.create() is called internally. * @param {number} [options.chunkSize=512] - Maximum number of tokens per chunk. Must be > 0. * @param {RecursiveRules} [options.rules=new RecursiveRules()] - Rules for recursive chunking. See {@link RecursiveRules} for customization. * @param {number} [options.minCharactersPerChunk=24] - Minimum number of characters per chunk. Must be > 0. * * @returns {Promise<CallableRecursiveChunker>} Promise resolving to a callable RecursiveChunker instance. * * @throws {Error} If any option is invalid (e.g., chunkSize <= 0). * * @see CallableRecursiveChunker for the callable interface and available properties/methods. * * @example <caption>Basic usage with default options</caption> * const chunker = await RecursiveChunker.create(); * const chunks = await chunker("Some text to chunk"); * * @example <caption>Custom options and batch chunking</caption> * const chunker = await RecursiveChunker.create({ chunkSize: 256 }); * const batchChunks = await chunker(["Text 1", "Text 2"]); * * @example <caption>Accessing properties and methods</caption> * const chunker = await RecursiveChunker.create(); * console.log(chunker.chunkSize); // 512 * console.log(chunker.rules); // RecursiveRules instance * const chunks = await chunker.chunk("Some text"); // Use as object method * * @note * The returned instance is both callable (like a function) and has all properties/methods of RecursiveChunker. * You can use it as a drop-in replacement for a function or a class instance. * * @note * For advanced customization, pass a custom RecursiveRules object to the rules option. * See {@link RecursiveRules} and {@link RecursiveLevel} for rule structure. */ static create() { return __awaiter(this, arguments, void 0, function* (options = {}) { const { tokenizer = "Xenova/gpt2", chunkSize = 512, rules = new recursive_1.RecursiveRules(), minCharactersPerChunk = 24, } = options; let tokenizerInstance; if (typeof tokenizer === 'string') { tokenizerInstance = yield tokenizer_1.Tokenizer.create(tokenizer); } else { tokenizerInstance = tokenizer; } const plainInstance = new RecursiveChunker(tokenizerInstance, chunkSize, rules, minCharactersPerChunk); // Create the callable function wrapper const callableFn = function (textOrTexts, showProgress) { if (typeof textOrTexts === 'string') { return plainInstance.call(textOrTexts, showProgress); } else { return plainInstance.call(textOrTexts, showProgress); } }; // Set the prototype so that 'instanceof RecursiveChunker' works Object.setPrototypeOf(callableFn, RecursiveChunker.prototype); // Copy all enumerable own properties from plainInstance to callableFn Object.assign(callableFn, plainInstance); return callableFn; }); } /** * Creates and initializes a RecursiveChunker instance from a recipe that is directly callable. * * This method loads a recipe from the Chonkie hub and uses the recipe's recursive rules * to configure the RecursiveChunker. The recipe rules override the default rules. * * @param {RecursiveChunkerRecipeOptions} [options] - Options for configuring the RecursiveChunker with recipe settings. * @returns {Promise<CallableRecursiveChunker>} A promise that resolves to a callable RecursiveChunker instance. * * @example * const chunker = await RecursiveChunker.fromRecipe({ name: 'default', language: 'en' }); * const chunks = await chunker("This is a sample text."); * * @see RecursiveChunkerRecipeOptions */ static fromRecipe() { return __awaiter(this, arguments, void 0, function* (options = {}) { var _a, _b; const { name = 'default', language = 'en', filePath, tokenizer = "Xenova/gpt2", chunkSize = 512, minCharactersPerChunk = 24 } = options; // Load the recipe using Hubbie const hubbie = new hub_1.Hubbie(); const recipe = yield hubbie.getRecipe(name, language, filePath); // Extract recursive_rules from recipe and create RecursiveRules let rules; if ((_b = (_a = recipe.recipe) === null || _a === void 0 ? void 0 : _a.recursive_rules) === null || _b === void 0 ? void 0 : _b.levels) { const levels = recipe.recipe.recursive_rules.levels.map((levelData) => { // Map from recipe format to RecursiveLevel format const levelConfig = { whitespace: levelData.whitespace || false, includeDelim: levelData.include_delim || 'prev' }; // Only add delimiters if they exist and are not null if (levelData.delimiters != null) { levelConfig.delimiters = levelData.delimiters; } return new recursive_1.RecursiveLevel(levelConfig); }); rules = new recursive_1.RecursiveRules({ levels: levels.map((level) => level.toDict()) }); } else { // Fallback to default rules if no recursive_rules in recipe rules = new recursive_1.RecursiveRules(); } // Create the RecursiveChunker using the regular create method with recipe values return RecursiveChunker.create({ tokenizer, chunkSize, rules, minCharactersPerChunk }); }); } /** * Estimates the number of tokens in a given text. * * This method uses a character-to-token ratio (default: 6.5 characters per token) for quick estimation. * If the estimated token count exceeds the chunk size, it performs an actual token count. * * @param {string} text - The text to estimate token count for * @returns {Promise<number>} A promise that resolves to the estimated number of tokens * @private */ _estimateTokenCount(text) { return __awaiter(this, void 0, void 0, function* () { const estimate = Math.max(1, Math.floor(text.length / this._CHARS_PER_TOKEN)); return estimate > this.chunkSize ? this.chunkSize + 1 : yield this.tokenizer.countTokens(text); }); } /** * Split the text into chunks based on the provided recursive level rules. * * This method handles three different splitting strategies: * 1. Whitespace-based splitting: Splits text on spaces * 2. Delimiter-based splitting: Splits text on specified delimiters with options to include delimiters * 3. Token-based splitting: Splits text into chunks of maximum token size * * @param {string} text - The text to be split into chunks * @param {RecursiveLevel} recursiveLevel - The rules defining how to split the text * @returns {Promise<string[]>} A promise that resolves to an array of text chunks * @private */ _splitText(text, recursiveLevel) { return __awaiter(this, void 0, void 0, function* () { // At every delimiter, replace it with the sep if (recursiveLevel.whitespace) { return text.split(" "); } else if (recursiveLevel.delimiters) { let t = text; if (recursiveLevel.includeDelim === "prev") { for (const delimiter of Array.isArray(recursiveLevel.delimiters) ? recursiveLevel.delimiters : [recursiveLevel.delimiters]) { t = t.replace(delimiter, delimiter + this.sep); } } else if (recursiveLevel.includeDelim === "next") { for (const delimiter of Array.isArray(recursiveLevel.delimiters) ? recursiveLevel.delimiters : [recursiveLevel.delimiters]) { t = t.replace(delimiter, this.sep + delimiter); } } else { for (const delimiter of Array.isArray(recursiveLevel.delimiters) ? recursiveLevel.delimiters : [recursiveLevel.delimiters]) { t = t.replace(delimiter, this.sep); } } const splits = t.split(this.sep).filter(split => split !== ""); // Merge short splits let current = ""; const merged = []; for (const split of splits) { if (split.length < this.minCharactersPerChunk) { current += split; } else if (current) { current += split; merged.push(current); current = ""; } else { merged.push(split); } if (current.length >= this.minCharactersPerChunk) { merged.push(current); current = ""; } } if (current) { merged.push(current); } return merged; } else { // Encode, Split, and Decode const encoded = yield this.tokenizer.encode(text); const tokenSplits = []; for (let i = 0; i < encoded.length; i += this.chunkSize) { tokenSplits.push(encoded.slice(i, i + this.chunkSize)); } return yield this.tokenizer.decodeBatch(tokenSplits); } }); } /** * Create a RecursiveChunk object with indices based on the current offset. * * This method constructs a RecursiveChunk object that contains metadata about the chunk, * including the text content, its start and end indices, token count, and the level of recursion. * * @param {string} text - The text content of the chunk * @param {number} tokenCount - The number of tokens in the chunk */ _makeChunks(text, tokenCount, level, startOffset) { return new recursive_1.RecursiveChunk({ text: text, startIndex: startOffset, endIndex: startOffset + text.length, tokenCount: tokenCount, level: level }); } /** * Merge short splits. */ _mergeSplits(splits, tokenCounts, combineWhitespace = false) { if (!splits.length || !tokenCounts.length) { return [[], []]; } // If the number of splits and token counts does not match, raise an error if (splits.length !== tokenCounts.length) { throw new Error(`Number of splits ${splits.length} does not match number of token counts ${tokenCounts.length}`); } // If all splits are larger than the chunk size, return them if (tokenCounts.every(count => count > this.chunkSize)) { return [splits, tokenCounts]; } // If the splits are too short, merge them const merged = []; const cumulativeTokenCounts = []; let sum = 0; if (combineWhitespace) { // +1 for the whitespace cumulativeTokenCounts.push(0); for (const count of tokenCounts) { sum += count + 1; cumulativeTokenCounts.push(sum); } } else { cumulativeTokenCounts.push(0); for (const count of tokenCounts) { sum += count; cumulativeTokenCounts.push(sum); } } let currentIndex = 0; const combinedTokenCounts = []; while (currentIndex < splits.length) { const currentTokenCount = cumulativeTokenCounts[currentIndex]; const requiredTokenCount = currentTokenCount + this.chunkSize; // Find the index to merge at let index = this._bisectLeft(cumulativeTokenCounts, requiredTokenCount, currentIndex) - 1; index = Math.min(index, splits.length); // If currentIndex == index, we need to move to the next index if (index === currentIndex) { index += 1; } // Merge splits if (combineWhitespace) { merged.push(splits.slice(currentIndex, index).join(" ")); } else { merged.push(splits.slice(currentIndex, index).join("")); } // Adjust token count combinedTokenCounts.push(cumulativeTokenCounts[Math.min(index, splits.length)] - currentTokenCount); currentIndex = index; } return [merged, combinedTokenCounts]; } /** * Binary search to find the leftmost position where value should be inserted to maintain order. * * @param {number[]} arr - The array to search * @param {number} value - The value to insert * @param {number} [lo=0] - The starting index for the search * @returns {number} The index where the value should be inserted * @private */ _bisectLeft(arr, value, lo = 0) { let hi = arr.length; while (lo < hi) { const mid = (lo + hi) >>> 1; if (arr[mid] < value) { lo = mid + 1; } else { hi = mid; } } return lo; } /** * Recursive helper for core chunking. */ _recursiveChunk(text_1) { return __awaiter(this, arguments, void 0, function* (text, level = 0, startOffset = 0) { if (!text) { return []; } if (level >= this.rules.length) { const tokenCount = yield this._estimateTokenCount(text); return [ this._makeChunks(text, tokenCount, level, startOffset) ]; } const currRule = this.rules.getLevel(level); if (!currRule) { throw new Error(`No rule found at level ${level}`); } const splits = yield this._splitText(text, currRule); const tokenCounts = yield Promise.all(splits.map(split => this._estimateTokenCount(split))); let merged; let combinedTokenCounts; if (currRule.delimiters === undefined && !currRule.whitespace) { [merged, combinedTokenCounts] = [splits, tokenCounts]; } else if (currRule.delimiters === undefined && currRule.whitespace) { [merged, combinedTokenCounts] = this._mergeSplits(splits, tokenCounts, true); // NOTE: This is a hack to fix the reconstruction issue when whitespace is used. // When whitespace is there, " ".join only adds space between words, not before the first word. // To make it combine back properly, all splits except the first one are prefixed with a space. merged = merged.slice(0, 1).concat(merged.slice(1).map(text => " " + text)); } else { [merged, combinedTokenCounts] = this._mergeSplits(splits, tokenCounts, false); } // Chunk long merged splits const chunks = []; let currentOffset = startOffset; for (let i = 0; i < merged.length; i++) { const split = merged[i]; const tokenCount = combinedTokenCounts[i]; if (tokenCount > this.chunkSize) { chunks.push(...yield this._recursiveChunk(split, level + 1, currentOffset)); } else { chunks.push(this._makeChunks(split, tokenCount, level, currentOffset)); } // Update the offset by the length of the processed split. currentOffset += split.length; } return chunks; }); } /** * Recursively chunk text. * * This method is the main entry point for chunking text using the RecursiveChunker. * It takes a single text string and returns an array of RecursiveChunk objects. * * @param {string} text - The text to be chunked * @returns {Promise<RecursiveChunk[]>} A promise that resolves to an array of RecursiveChunk objects */ chunk(text) { return __awaiter(this, void 0, void 0, function* () { const result = yield this._recursiveChunk(text, 0, 0); return result; }); } /** * Return a string representation of the RecursiveChunker. * * This method provides a string representation of the RecursiveChunker instance, * including its tokenizer, rules, chunk size, minimum characters per chunk, and return type. * * @returns {string} A string representation of the RecursiveChunker */ toString() { return `RecursiveChunker(tokenizer=${this.tokenizer}, ` + `rules=${this.rules}, chunkSize=${this.chunkSize}, ` + `minCharactersPerChunk=${this.minCharactersPerChunk})`; } } exports.RecursiveChunker = RecursiveChunker; //# sourceMappingURL=recursive.js.map