Skip to content

Commit eb97e61

Browse files
authored
fix(ai): chunk oversized kb sources before embedding (#14000) (#14003)
* fix(ai): chunk oversized kb sources before embedding (#14000) * test(ai): avoid AiConfig mutation in KB ingestion spec (#14000)
1 parent 6b7a6e7 commit eb97e61

2 files changed

Lines changed: 276 additions & 76 deletions

File tree

ai/services/knowledge-base/KnowledgeBaseIngestionService.mjs

Lines changed: 181 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)