@@ -474,8 +474,8 @@ class KnowledgeBaseIngestionService extends Base {
474474 */
475475 async getTenantManifest ( { tenantId, repoSlug} = { } ) {
476476 const { tenantId : resolvedTenant , repoSlug : resolvedRepo } = this . resolveTenantContext ( { tenantId, repoSlug} ) ;
477- const manifests = await this . getTenantManifests ( { tenantId : resolvedTenant } ) ;
478- const manifest = manifests [ resolvedRepo ] ;
477+ const manifests = await this . getTenantManifests ( { tenantId : resolvedTenant } ) ;
478+ const manifest = manifests [ resolvedRepo ] ;
479479
480480 return {
481481 tenantId : resolvedTenant ,
@@ -710,29 +710,192 @@ class KnowledgeBaseIngestionService extends Base {
710710 return chunks ;
711711 }
712712
713- return chunks . filter ( chunk => {
714- const
715- text = this . buildEmbeddingInputText ( chunk ) ,
716- inputBytes = Buffer . byteLength ( text , 'utf8' ) ,
717- inputTokensEstimate = bytesToTokens ( inputBytes ) ;
713+ const embeddable = [ ] ;
718714
719- if ( inputTokensEstimate <= guardrail . safeProcessingLimitTokens ) {
720- return true ;
715+ for ( const chunk of chunks ) {
716+ const budget = this . evaluateEmbeddingInputBudget ( chunk , guardrail ) ;
717+
718+ if ( ! budget . skip ) {
719+ embeddable . push ( chunk ) ;
720+ continue ;
721721 }
722722
723- this . recordOversizedEmbeddingSkip ( {
724- chunk,
725- guardrail,
726- inputBytes,
727- inputTokensEstimate,
728- summary,
729- tenantContext
730- } ) ;
723+ const splitChunks = this . splitOversizedEmbeddingChunk ( { chunk, guardrail, tenantContext} ) ;
724+
725+ if ( splitChunks . length <= 1 ) {
726+ this . recordOversizedEmbeddingSkip ( {
727+ chunk,
728+ guardrail,
729+ summary,
730+ tenantContext,
731+ ...budget
732+ } ) ;
733+ continue ;
734+ }
735+
736+ for ( const splitChunk of splitChunks ) {
737+ const splitBudget = this . evaluateEmbeddingInputBudget ( splitChunk , guardrail ) ;
738+
739+ if ( ! splitBudget . skip ) {
740+ embeddable . push ( splitChunk ) ;
741+ continue ;
742+ }
743+
744+ this . recordOversizedEmbeddingSkip ( {
745+ chunk : splitChunk ,
746+ guardrail,
747+ summary,
748+ tenantContext,
749+ ...splitBudget
750+ } ) ;
751+ }
752+ }
753+
754+ return embeddable ;
755+ }
756+
757+ /**
758+ * @summary Evaluates the final embedding input shape against the local provider budget.
759+ * @param {Object } chunk Normalized parsed chunk.
760+ * @param {Object } guardrail Local embedding guardrail.
761+ * @returns {{skip: Boolean, inputBytes: Number, inputTokensEstimate: Number} }
762+ * @protected
763+ */
764+ evaluateEmbeddingInputBudget ( chunk , guardrail ) {
765+ const text = this . buildEmbeddingInputText ( chunk ) ,
766+ inputBytes = Buffer . byteLength ( text , 'utf8' ) ,
767+ inputTokensEstimate = bytesToTokens ( inputBytes ) ;
768+
769+ return {
770+ skip : inputTokensEstimate > guardrail . safeProcessingLimitTokens ,
771+ inputBytes,
772+ inputTokensEstimate
773+ } ;
774+ }
775+
776+ /**
777+ * @summary Splits a recoverable oversized text chunk into deterministic embedding-safe sub-chunks.
778+ * @param {Object } options
779+ * @returns {Object[] } Either multiple sub-chunks or the original chunk when no safe split is possible.
780+ * @protected
781+ */
782+ splitOversizedEmbeddingChunk ( { chunk, guardrail, tenantContext} ) {
783+ const content = chunk . content || chunk . description ;
784+
785+ if ( typeof content !== 'string' || content . length === 0 ) {
786+ return [ chunk ] ;
787+ }
788+
789+ const maxInputBytes = Math . max ( 1 , guardrail . safeProcessingLimitTokens * 3 ) ,
790+ prefixBytes = Buffer . byteLength ( `${ chunk . type } : ${ chunk . name } in ${ chunk . className || '' } \n` , 'utf8' ) ,
791+ maxContentBytes = Math . max ( 1 , maxInputBytes - prefixBytes - 128 ) ,
792+ parts = this . splitTextByByteBudget ( content , maxContentBytes ) ;
731793
732- return false ;
794+ if ( parts . length <= 1 ) {
795+ return [ chunk ] ;
796+ }
797+
798+ let charStart = 0 ;
799+
800+ return parts . map ( ( part , index ) => {
801+ const charEnd = charStart + part . length ,
802+ child = {
803+ ...chunk ,
804+ content : part ,
805+ description : part ,
806+ name : `${ chunk . name } [part ${ index + 1 } /${ parts . length } ]` ,
807+ hashInputs : Array . from ( new Set ( [
808+ ...( chunk . hashInputs || [ ] ) ,
809+ 'oversizedSplitIndex' ,
810+ 'oversizedSplitTotal' ,
811+ 'oversizedSplitCharStart' ,
812+ 'oversizedSplitCharEnd'
813+ ] ) ) ,
814+ oversizedSplit : true ,
815+ oversizedSplitIndex : index ,
816+ oversizedSplitTotal : parts . length ,
817+ oversizedSplitCharStart : charStart ,
818+ oversizedSplitCharEnd : charEnd
819+ } ;
820+
821+ charStart = charEnd ;
822+
823+ const hash = this . createChunkHash ( child , tenantContext ) ;
824+
825+ child . hash = hash ;
826+ child . id = hash ;
827+
828+ return child ;
733829 } ) ;
734830 }
735831
832+ /**
833+ * @summary Splits text on stable line boundaries, falling back to character slices for single huge lines.
834+ * @param {String } text Source text.
835+ * @param {Number } maxBytes Maximum byte size per returned part.
836+ * @returns {String[] }
837+ * @protected
838+ */
839+ splitTextByByteBudget ( text , maxBytes ) {
840+ if ( Buffer . byteLength ( text , 'utf8' ) <= maxBytes ) {
841+ return [ text ] ;
842+ }
843+
844+ const parts = [ ] ;
845+ let current = '' ;
846+
847+ for ( const line of text . match ( / [ ^ \n ] * \n ? | [ ^ \n ] + $ / g) . filter ( Boolean ) ) {
848+ if ( Buffer . byteLength ( line , 'utf8' ) > maxBytes ) {
849+ if ( current ) {
850+ parts . push ( current ) ;
851+ current = '' ;
852+ }
853+ parts . push ( ...this . splitLongStringByByteBudget ( line , maxBytes ) ) ;
854+ continue ;
855+ }
856+
857+ if ( current && Buffer . byteLength ( current + line , 'utf8' ) > maxBytes ) {
858+ parts . push ( current ) ;
859+ current = line ;
860+ } else {
861+ current += line ;
862+ }
863+ }
864+
865+ if ( current ) {
866+ parts . push ( current ) ;
867+ }
868+
869+ return parts . filter ( part => part . length > 0 ) ;
870+ }
871+
872+ /**
873+ * @summary Splits one oversized line without breaking JavaScript surrogate pairs.
874+ * @param {String } value Source string.
875+ * @param {Number } maxBytes Maximum byte size per part.
876+ * @returns {String[] }
877+ * @protected
878+ */
879+ splitLongStringByByteBudget ( value , maxBytes ) {
880+ const parts = [ ] ;
881+ let current = '' ;
882+
883+ for ( const char of value ) {
884+ if ( current && Buffer . byteLength ( current + char , 'utf8' ) > maxBytes ) {
885+ parts . push ( current ) ;
886+ current = char ;
887+ } else {
888+ current += char ;
889+ }
890+ }
891+
892+ if ( current ) {
893+ parts . push ( current ) ;
894+ }
895+
896+ return parts ;
897+ }
898+
736899 /**
737900 * @summary Records a bounded oversized-ingestion diagnostic without exposing raw content.
738901 * @param {Object } options
0 commit comments