fix(semantic-index): harden embedding input guards

This commit is contained in:
digua committed 2026-07-10 15:11:51 +08:00
1 parent be60b3ccfb
commit b2f20a15bd
3 files changed
+133 -17

No files matched your search

@@ -132,6 +132,86 @@ test('single oversized message is clamped to the child token hard limit', () =>
assert.ok(estimateTokens(result.chunks[0].embeddingInput) <= cfg.childHardMaxTokens)
})
test('single oversized non-CJK input also respects the UTF-8 byte safety ceiling', () => {
const cfg: ChunkerConfig = {
...DEFAULT_CHUNKER_CONFIG,
parentGapSeconds: 100000,
parentMaxTokens: 100000,
childTargetMaxChars: 100000,
childHardMaxTokens: 120,
semanticVoidSkipThreshold: 1,
}
const result = chunkMessages({
messages: [msg(1, '😀'.repeat(1000), 0)],
source: privateSource,
config: cfg,
})
const embeddingInput = result.chunks[0].embeddingInput
assert.ok(Buffer.byteLength(embeddingInput, 'utf8') <= cfg.childHardMaxTokens * 4)
})
test('oversized source title cannot consume the message body budget', () => {
const cfg: ChunkerConfig = {
...DEFAULT_CHUNKER_CONFIG,
parentGapSeconds: 100000,
parentMaxTokens: 100000,
childTargetMaxChars: 100000,
childHardMaxTokens: 120,
semanticVoidSkipThreshold: 1,
}
const marker = '正文唯一标记内容'
const result = chunkMessages({
messages: [msg(1, marker.repeat(10), 0)],
source: { title: '群'.repeat(1000), kind: 'private' },
config: cfg,
})
assert.ok(result.chunks[0].embeddingInput.includes(marker))
})
test('oversized sender name cannot consume the message content budget', () => {
const cfg: ChunkerConfig = {
...DEFAULT_CHUNKER_CONFIG,
parentGapSeconds: 100000,
parentMaxTokens: 100000,
childTargetMaxChars: 100000,
childHardMaxTokens: 120,
semanticVoidSkipThreshold: 1,
}
const marker = '消息正文唯一标记'
const result = chunkMessages({
messages: [msg(1, marker.repeat(10), 0, '成员'.repeat(1000))],
source: privateSource,
config: cfg,
})
assert.ok(result.chunks[0].embeddingInput.includes(marker))
})
test('repeated oversized sender names share one metadata budget across the body', () => {
const cfg: ChunkerConfig = {
...DEFAULT_CHUNKER_CONFIG,
parentGapSeconds: 100000,
parentMaxTokens: 100000,
childTargetMinChars: 100000,
childTargetMaxChars: 100000,
childSoftMaxMessages: 100,
childHardMaxMessages: 100,
childHardMaxTokens: 1200,
overlapMessages: 0,
semanticVoidSkipThreshold: 1,
}
const marker = '末条消息正文唯一标记'
const messages = Array.from({ length: 20 }, (_, index) =>
msg(index + 1, index === 19 ? marker : `前置正文${index}`, 0, '成员'.repeat(1000))
)
const result = chunkMessages({ messages, source: privateSource, config: cfg })
assert.equal(result.chunks.length, 1)
assert.ok(result.chunks[0].embeddingInput.includes(marker))
})
test('semantic void messages are excluded from embedding body, header and effective chars', () => {
const messages = [
msg(1, '我们讨论一下下周的装修进度安排', 0, '张三'),
@@ -183,6 +183,10 @@ function sha256Hex(input: string): string {
// ==================== header / body ====================
const EMBEDDING_HEADER_BUDGET_RATIO = 0.25
const SOURCE_TITLE_HEADER_SHARE = 1 / 3
const SENDER_NAMES_BODY_BUDGET_RATIO = 0.1
function pad2(n: number): string {
return String(n).padStart(2, '0')
}
@@ -207,24 +211,45 @@ function formatTimeRange(startTs: number, endTs: number): string {
return `${formatDateTime(startTs)} - ${sameDay ? formatTime(endTs) : formatDateTime(endTs)}`
}
function buildEmbeddingInput(source: ChunkSource, messages: ChunkMessageInput[]): string {
/**
* 在 token/字节总护栏内组装 embedding 文本。
* header 最多占四分之一预算,异常长的来源名或发送者名只截断派生文本,正文始终优先保留。
*/
function buildEmbeddingInput(source: ChunkSource, messages: ChunkMessageInput[], maxTokens: number): string {
const limit = Math.max(0, Math.floor(maxTokens))
if (limit === 0) return ''
const effective = messages.filter((m) => !isSemanticVoid(m))
const startTs = messages[0].ts
const endTs = messages[messages.length - 1].ts
const lines: string[] = []
const headerLimit = Math.max(1, Math.floor(limit * EMBEDDING_HEADER_BUDGET_RATIO))
const bodyFloor = Math.max(1, limit - headerLimit)
const sourceTitleLimit = Math.max(1, Math.floor(headerLimit * SOURCE_TITLE_HEADER_SHARE))
const senderNamesTotalLimit = Math.floor(bodyFloor * SENDER_NAMES_BODY_BUDGET_RATIO)
const bodySenderNameLimit = effective.length > 0 ? Math.floor(senderNamesTotalLimit / effective.length) : 0
const clampHeaderSenderName = (name: string) => clampEstimatedTokens(name, sourceTitleLimit)
// 所有发送者名共享正文总预算,按有效消息数均分,避免多条异常长名称累计挤掉后半段正文。
const clampBodySenderName = (name: string) => clampEstimatedTokens(name, bodySenderNameLimit)
const headerLines: string[] = []
const kindLabel = source.kind === 'group' ? '群聊' : '私聊'
lines.push(`[来源] ${source.title}(${kindLabel})`)
lines.push(`[时间范围] ${formatTimeRange(startTs, endTs)}`)
headerLines.push(`[来源] ${clampEstimatedTokens(source.title, sourceTitleLimit)}(${kindLabel})`)
headerLines.push(`[时间范围] ${formatTimeRange(startTs, endTs)}`)
if (source.kind === 'group') {
const participants = [...new Set(effective.map((m) => m.senderName))]
lines.push(`[参与者] ${participants.join('、')}`)
const participants = [...new Set(effective.map((m) => clampHeaderSenderName(m.senderName)))]
headerLines.push(`[参与者] ${participants.join('、')}`)
}
lines.push('')
for (const m of effective) {
lines.push(`${m.senderName}: ${(m.content ?? '').trim()}`)
}
return lines.join('\n')
const header = clampEstimatedTokens(headerLines.join('\n'), headerLimit)
const separator = '\n\n'
const remaining = Math.max(0, limit - estimateTokens(header) - estimateTokens(separator))
const body = clampEstimatedTokens(
effective.map((m) => `${clampBodySenderName(m.senderName)}: ${(m.content ?? '').trim()}`).join('\n'),
remaining
)
const combined = body ? `${header}${separator}${body}` : header
return clampEstimatedTokens(combined, limit)
}
function rawContentHashOf(messages: ChunkMessageInput[]): string {
@@ -316,7 +341,7 @@ export function chunkMessages(input: ChunkMessagesInput): ChunkResult {
const startMessageId = draft[0].id
const endMessageId = draft[draft.length - 1].id
// 单条超长消息无法在消息边界上继续切分,因此在派生的 embedding 文本上执行最终硬限制。
const embeddingInput = clampEstimatedTokens(buildEmbeddingInput(input.source, draft), config.childHardMaxTokens)
const embeddingInput = buildEmbeddingInput(input.source, draft, config.childHardMaxTokens)
chunks.push({
localChunkId: `${parentId}#${startMessageId}-${endMessageId}`,
parentId,
@@ -4,6 +4,10 @@
* 仅作硬上限护栏与证据预算控制,真实 token 由 embedder / LLM 决定。
* 估算规则:CJK 每字约 1 token,其余字符约 1/4 token。
*/
import { Buffer } from 'node:buffer'
const UTF8_BYTES_PER_ESTIMATED_TOKEN = 4
function isCjkCharacter(char: string): boolean {
return /[\u3400-\u9fff\uf900-\ufaff]/u.test(char)
}
@@ -19,21 +23,28 @@ export function estimateTokens(text: string): number {
}
/**
* 按共享估算规则截断文本,确保单条超长消息不会绕过 chunk token 硬上限。
* 按共享估算规则与 UTF-8 字节上限截断文本,避免 emoji 等高膨胀字符绕过安全护栏。
* 这里只处理送给 embedding provider 的派生文本,不修改原始消息和证据范围。
*/
export function clampEstimatedTokens(text: string, maxTokens: number): string {
const limit = Math.max(0, Math.floor(maxTokens))
if (limit === 0) return ''
if (estimateTokens(text) <= limit) return text
const byteLimit = limit * UTF8_BYTES_PER_ESTIMATED_TOKEN
if (estimateTokens(text) <= limit && Buffer.byteLength(text, 'utf8') <= byteLimit) return text
let cjk = 0
let other = 0
let bytes = 0
let end = 0
for (const char of text) {
if (isCjkCharacter(char)) cjk++
else other++
if (cjk + Math.ceil(other / 4) > limit) break
const isCjk = isCjkCharacter(char)
const nextCjk = cjk + (isCjk ? 1 : 0)
const nextOther = other + (isCjk ? 0 : 1)
const nextBytes = bytes + Buffer.byteLength(char, 'utf8')
if (nextCjk + Math.ceil(nextOther / 4) > limit || nextBytes > byteLimit) break
cjk = nextCjk
other = nextOther
bytes = nextBytes
end += char.length
}
return text.slice(0, end)