fix(knowledge-fs): improve Chinese PDF ingestion and retrieval

This commit is contained in:
Stephen Zhou 2026-08-30 22:00:34 +08:00
parent 84c53bdfde
commit 138e810f9c
No known key found for this signature in database
17 changed files with 459 additions and 50 deletions

View File

@ -459,6 +459,8 @@ const documentSemanticChunker = createLlmSemanticChunker({
: {}),
maxConcurrentWindows: ingestionModelRuntimeOptions.semanticExtractionMaxConcurrency,
maxNodes: 20_000,
maxProviderOutputRetries: 1,
maxWindowChars: ingestionModelRuntimeOptions.semanticChunkingMaxWindowChars,
metrics: operationalMetrics.ingestionModelCalls,
modelRequestGate: ingestionModelRuntimeOptions.modelRequestGate,
reasoningProviderFactory: profileReasoningCapability.providerFactory,

View File

@ -14,6 +14,7 @@ describe("createApiIngestionModelRuntimeOptions", () => {
expect(options.outlineSummaryMaxConcurrency).toBe(8);
expect(options.semanticExtractionBatchSize).toBe(8);
expect(options.semanticExtractionMaxConcurrency).toBe(4);
expect(options.semanticChunkingMaxWindowChars).toBe(4_800);
});
it("accepts bounded concurrency overrides", () => {
@ -26,6 +27,7 @@ describe("createApiIngestionModelRuntimeOptions", () => {
KNOWLEDGE_OUTLINE_SUMMARY_MAX_CONCURRENCY: "12",
KNOWLEDGE_SEMANTIC_EXTRACTION_BATCH_SIZE: "10",
KNOWLEDGE_SEMANTIC_EXTRACTION_MAX_CONCURRENCY: "6",
KNOWLEDGE_SEMANTIC_CHUNKING_MAX_WINDOW_CHARS: "2400",
});
expect(options.globalConcurrency).toBe(24);
@ -36,6 +38,7 @@ describe("createApiIngestionModelRuntimeOptions", () => {
expect(options.outlineSummaryMaxConcurrency).toBe(12);
expect(options.semanticExtractionBatchSize).toBe(10);
expect(options.semanticExtractionMaxConcurrency).toBe(6);
expect(options.semanticChunkingMaxWindowChars).toBe(2_400);
});
it("creates an isolated hard budget for each document", () => {
@ -84,6 +87,7 @@ describe("createApiIngestionModelRuntimeOptions", () => {
["KNOWLEDGE_OUTLINE_SUMMARY_BATCH_MAX_INPUT_CHARS", "200001"],
["KNOWLEDGE_SEMANTIC_EXTRACTION_BATCH_SIZE", "33"],
["KNOWLEDGE_SEMANTIC_EXTRACTION_MAX_CONCURRENCY", "0"],
["KNOWLEDGE_SEMANTIC_CHUNKING_MAX_WINDOW_CHARS", "200001"],
] as const)("rejects invalid %s=%s", (name, value) => {
expect(() => createApiIngestionModelRuntimeOptions({ [name]: value })).toThrow(name);
});

View File

@ -15,6 +15,7 @@ export interface ApiIngestionModelRuntimeEnv {
readonly KNOWLEDGE_OUTLINE_SUMMARY_MAX_CONCURRENCY?: string | undefined;
readonly KNOWLEDGE_SEMANTIC_EXTRACTION_BATCH_SIZE?: string | undefined;
readonly KNOWLEDGE_SEMANTIC_EXTRACTION_MAX_CONCURRENCY?: string | undefined;
readonly KNOWLEDGE_SEMANTIC_CHUNKING_MAX_WINDOW_CHARS?: string | undefined;
}
export interface ApiIngestionModelRuntimeOptions {
@ -28,6 +29,7 @@ export interface ApiIngestionModelRuntimeOptions {
readonly outlineSummaryMaxConcurrency: number;
readonly semanticExtractionBatchSize: number;
readonly semanticExtractionMaxConcurrency: number;
readonly semanticChunkingMaxWindowChars: number;
}
export interface ApiIngestionModelRuntimeMetrics {
@ -48,8 +50,10 @@ const MAX_OUTLINE_SUMMARY_BATCH_SIZE = 32;
const MAX_OUTLINE_SUMMARY_CONCURRENCY = 32;
const DEFAULT_SEMANTIC_EXTRACTION_BATCH_SIZE = 8;
const DEFAULT_SEMANTIC_EXTRACTION_MAX_CONCURRENCY = 4;
const DEFAULT_SEMANTIC_CHUNKING_MAX_WINDOW_CHARS = 4_800;
const MAX_SEMANTIC_EXTRACTION_BATCH_SIZE = 32;
const MAX_SEMANTIC_EXTRACTION_CONCURRENCY = 32;
const MAX_SEMANTIC_CHUNKING_MAX_WINDOW_CHARS = 200_000;
/**
* Builds the shared ingestion-time model budget. The per-document outline bound protects fairness
@ -108,6 +112,12 @@ export function createApiIngestionModelRuntimeOptions(
name: "KNOWLEDGE_SEMANTIC_EXTRACTION_MAX_CONCURRENCY",
value: env.KNOWLEDGE_SEMANTIC_EXTRACTION_MAX_CONCURRENCY,
});
const semanticChunkingMaxWindowChars = boundedPositiveIntegerEnv({
fallback: DEFAULT_SEMANTIC_CHUNKING_MAX_WINDOW_CHARS,
max: MAX_SEMANTIC_CHUNKING_MAX_WINDOW_CHARS,
name: "KNOWLEDGE_SEMANTIC_CHUNKING_MAX_WINDOW_CHARS",
value: env.KNOWLEDGE_SEMANTIC_CHUNKING_MAX_WINDOW_CHARS,
});
return {
createDocumentModelBudget: () =>
@ -126,6 +136,7 @@ export function createApiIngestionModelRuntimeOptions(
outlineSummaryMaxConcurrency,
semanticExtractionBatchSize,
semanticExtractionMaxConcurrency,
semanticChunkingMaxWindowChars,
};
}

View File

@ -79,6 +79,31 @@ describe("createApiDocumentParser", () => {
});
});
it("forwards the configured default language to Unstructured", async () => {
let requestedLanguage: FormDataEntryValue | null = null;
const parser = createApiDocumentParser({
env: {
UNSTRUCTURED_API_URL: "https://unstructured.example.test",
UNSTRUCTURED_DEFAULT_LANGUAGE: "zh-CN",
},
fetch: async (input) => {
const request = input instanceof Request ? input : new Request(input);
requestedLanguage = (await request.formData()).get("languages");
return new Response("[]", { headers: { "content-type": "application/json" } });
},
});
await parser.parse({
body: encoder.encode("%PDF-1.7"),
documentAssetId: "00000000-0000-4000-8000-000000000007",
filename: "report.pdf",
mimeType: "application/pdf",
version: 1,
});
expect(requestedLanguage).toBe("zho");
});
it("can derive the local Unstructured URL from UNSTRUCTURED_PORT outside production", async () => {
let requestedUrl = "";
const parser = createApiDocumentParser({

View File

@ -11,6 +11,7 @@ export interface ApiParserEnv {
readonly NODE_ENV?: string | undefined;
readonly UNSTRUCTURED_API_KEY?: string | undefined;
readonly UNSTRUCTURED_API_URL?: string | undefined;
readonly UNSTRUCTURED_DEFAULT_LANGUAGE?: string | undefined;
readonly UNSTRUCTURED_MAX_CONCURRENCY?: string | undefined;
readonly UNSTRUCTURED_MAX_RESPONSE_BYTES?: string | undefined;
readonly UNSTRUCTURED_MAX_RETRIES?: string | undefined;
@ -60,6 +61,9 @@ function createApiUnstructuredParser({
}
return createUnstructuredParserClient({
...(env.UNSTRUCTURED_DEFAULT_LANGUAGE?.trim()
? { defaultLanguage: env.UNSTRUCTURED_DEFAULT_LANGUAGE.trim() }
: {}),
endpoint,
...(env.UNSTRUCTURED_API_KEY?.trim() ? { apiKey: env.UNSTRUCTURED_API_KEY.trim() } : {}),
...(fetchImpl ? { fetch: fetchImpl } : {}),

View File

@ -14,6 +14,8 @@ UNSTRUCTURED_PORT=8000
UNSTRUCTURED_API_URL=http://127.0.0.1:8000
UNSTRUCTURED_API_KEY=
UNSTRUCTURED_MAX_CONCURRENCY=2
UNSTRUCTURED_DEFAULT_LANGUAGE=
KNOWLEDGE_SEMANTIC_CHUNKING_MAX_WINDOW_CHARS=4800
UNSTRUCTURED_REQUEST_TIMEOUT_MS=120000
UNSTRUCTURED_MAX_RESPONSE_BYTES=33554432
UNSTRUCTURED_MAX_RETRIES=

View File

@ -61,6 +61,8 @@ services:
KNOWLEDGE_DEV_TENANT_ID: ${KNOWLEDGE_DEV_TENANT_ID:-tenant-dev}
UNSTRUCTURED_API_URL: ${UNSTRUCTURED_API_URL:-http://unstructured:8000}
UNSTRUCTURED_MAX_CONCURRENCY: ${UNSTRUCTURED_MAX_CONCURRENCY:-2}
UNSTRUCTURED_DEFAULT_LANGUAGE: ${UNSTRUCTURED_DEFAULT_LANGUAGE:-}
KNOWLEDGE_SEMANTIC_CHUNKING_MAX_WINDOW_CHARS: ${KNOWLEDGE_SEMANTIC_CHUNKING_MAX_WINDOW_CHARS:-4800}
UNSTRUCTURED_REQUEST_TIMEOUT_MS: ${UNSTRUCTURED_REQUEST_TIMEOUT_MS:-120000}
UNSTRUCTURED_MAX_RESPONSE_BYTES: ${UNSTRUCTURED_MAX_RESPONSE_BYTES:-33554432}
UNSTRUCTURED_MAX_RETRIES: ${UNSTRUCTURED_MAX_RETRIES:-0}

View File

@ -118,8 +118,18 @@ describe("document compilation candidate runtime factories", () => {
);
expect(resolved.material.projections).toEqual(
expect.arrayContaining([
expect.objectContaining({ model: vectorSpaceId, type: "dense-vector" }),
expect.objectContaining({ model: "database-fts@1", type: "fts" }),
expect.objectContaining({
indexVersion: "dify-model-runtime-embedding-v1",
model: vectorSpaceId,
strategy: "text-dense-v1:section-context-v1",
type: "dense-vector",
}),
expect.objectContaining({
indexVersion: "database-fts-v1",
model: "database-fts@1",
strategy: "mixed-cjk-latin-fts-v1:section-context-v1",
type: "fts",
}),
]),
);
});

View File

@ -42,6 +42,7 @@ import type {
ProjectionSetPublicationMemberRepository,
} from "./projection-publication-member-repository";
import type { ProjectionSetPublicationRepository } from "./projection-publication-repository";
import { TEXT_INDEXING_STRATEGY } from "./text-indexing-strategy";
export interface DocumentCompilationFingerprintVersions {
readonly chunkerVersion: string;
@ -655,11 +656,11 @@ function projectionFingerprintConfig(
const visual = isVisualProjection(projection);
const strategy =
projection.type === "fts"
? "mixed-cjk-latin-fts-v1"
? `mixed-cjk-latin-fts-v1:${TEXT_INDEXING_STRATEGY}`
: visual
? "visual-dense-v1"
: projection.type === "dense-vector"
? "text-dense-v1"
? `text-dense-v1:${TEXT_INDEXING_STRATEGY}`
: `${projection.type}-v1`;
return {
indexVersion:

View File

@ -8842,7 +8842,7 @@ describe("createKnowledgeGateway", () => {
expect(embedding.calls[0]).toEqual({
inputType: "search_document",
model: "static-upgrade@2026-05-01",
texts: ["First chunk", "Second chunk"],
texts: ["Intro\n\nFirst chunk", "Intro\n\nSecond chunk"],
});
expect(evaluationCalls[0]).toEqual({
denseProjectionModel: "static-upgrade@2026-05-01",
@ -9132,7 +9132,7 @@ describe("createKnowledgeGateway", () => {
{
inputType: "search_document",
model: "static-dense",
texts: ["First chunk", "Second chunk"],
texts: ["Intro\n\nFirst chunk", "Intro\n\nSecond chunk"],
},
]);
expect(projections).toEqual([
@ -9141,7 +9141,7 @@ describe("createKnowledgeGateway", () => {
knowledgeSpaceId: firstNode.knowledgeSpaceId,
metadata: expect.objectContaining({
artifactHash: firstNode.artifactHash,
denseVector: [0.1, 11],
denseVector: [0.1, 18],
dimension: 2,
embeddingProvider: "static",
modelVersion: "static-dense",
@ -9153,7 +9153,7 @@ describe("createKnowledgeGateway", () => {
type: "dense-vector",
}),
expect.objectContaining({
metadata: expect.objectContaining({ denseVector: [1.1, 12] }),
metadata: expect.objectContaining({ denseVector: [1.1, 19] }),
nodeId: secondNode.id,
}),
]);
@ -9187,7 +9187,7 @@ describe("createKnowledgeGateway", () => {
type: "dense-vector",
})
).items[0]?.metadata,
).toEqual(expect.objectContaining({ denseVector: [0.1, 11] }));
).toEqual(expect.objectContaining({ denseVector: [0.1, 18] }));
await expect(
memoryRepository.listReadyBySpace({
knowledgeSpaceId: firstNode.knowledgeSpaceId,
@ -9394,7 +9394,7 @@ describe("createKnowledgeGateway", () => {
);
expect(fake.calls[0]?.sql).toContain("dense_vector");
expect(fake.calls[0]?.sql).not.toContain("First chunk");
expect(fake.calls[0]?.params).toContain("[0.1,11]");
expect(fake.calls[0]?.params).toContain("[0.1,18]");
expect(fake.calls[0]?.params).toContain(JSON.stringify(firstProjection.metadata));
await expect(
databaseRepository.listReadyBySpace({
@ -9635,7 +9635,8 @@ describe("createKnowledgeGateway", () => {
metadata: expect.objectContaining({
artifactHash: firstNode.artifactHash,
ftsLanguageStrategy: "mixed-cjk-latin-v1",
ftsText: "contract abc 123 renewal terms",
ftsText: "intro contract abc 123 renewal terms",
indexingStrategy: "section-context-v1",
parser: "database-fts",
}),
model: "database-fts@1",
@ -9646,7 +9647,7 @@ describe("createKnowledgeGateway", () => {
}),
expect.objectContaining({
metadata: expect.objectContaining({
ftsText: "error code e 42 remediation",
ftsText: "intro error code e 42 remediation",
}),
nodeId: secondNode.id,
}),
@ -9680,7 +9681,7 @@ describe("createKnowledgeGateway", () => {
);
expect(fake.calls[0]?.sql).toContain("to_tsvector('simple'");
expect(fake.calls[0]?.sql).not.toContain("Contract ABC-123");
expect(fake.calls[0]?.params).toContain("contract abc 123 renewal terms");
expect(fake.calls[0]?.params).toContain("intro contract abc 123 renewal terms");
expect(fake.calls[0]?.params).toContain(JSON.stringify(firstProjection.metadata));
const tidbFake = createFakeIndexProjectionExecutor();
const tidbRepository = createDatabaseIndexProjectionRepository({
@ -9695,7 +9696,7 @@ describe("createKnowledgeGateway", () => {
await expect(tidbRepository.createMany([firstProjection])).resolves.toEqual([firstProjection]);
expect(tidbFake.calls[0]?.sql).toContain("INSERT INTO `index_projections`");
expect(tidbFake.calls[0]?.sql).not.toContain("to_tsvector");
expect(tidbFake.calls[0]?.params).toContain("contract abc 123 renewal terms");
expect(tidbFake.calls[0]?.params).toContain("intro contract abc 123 renewal terms");
});
it("idempotently reindexes parse artifacts so interrupted projection builds can be repaired", async () => {
@ -9980,7 +9981,8 @@ describe("createKnowledgeGateway", () => {
expect(projections[0]?.metadata).toEqual(
expect.objectContaining({
ftsLanguageStrategy: "mixed-cjk-latin-v1",
ftsText: "合 同 abc 123 续 约 terms",
ftsText: "intro 合 同 abc 123 续 约 terms",
indexingStrategy: "section-context-v1",
}),
);
});

View File

@ -235,7 +235,11 @@ describe("index projection builders", () => {
});
expect(embedCalls).toEqual([
{ inputType: "search_document", model: "model-a", texts: ["合同ABC-123续约 terms"] },
{
inputType: "search_document",
model: "model-a",
texts: ["Intro\n\n合同ABC-123续约 terms"],
},
]);
expect(result[0]).toMatchObject({
id: "018f0d60-7a49-7cc2-9c1b-5b36f18f9000",
@ -298,7 +302,7 @@ describe("index projection builders", () => {
inputType: "search_document",
model: "tenant-model",
tenantId: "tenant-1",
texts: ["合同ABC-123续约 terms"],
texts: ["Intro\n\n合同ABC-123续约 terms"],
},
]);
expect(projection).toMatchObject({
@ -527,7 +531,8 @@ describe("index projection builders", () => {
expect(projection).toMatchObject({
metadata: {
ftsLanguageStrategy: "mixed-cjk-latin-v1",
ftsText: "合 同 abc 123 续 约 terms",
ftsText: "intro 合 同 abc 123 续 约 terms",
indexingStrategy: "section-context-v1",
parser: "database-fts",
},
model: "database-fts@1",
@ -536,6 +541,59 @@ describe("index projection builders", () => {
});
});
it("indexes semantic section paths and summaries while preserving the node source text", async () => {
const embedCalls: EmbedTextsInput[] = [];
const embeddings: EmbeddingProvider = {
embed: async (input) => {
embedCalls.push(input);
return {
dense: [[0.1, 0.2]],
metadata: { model: "model-a@1", provider: "static" },
model: "model-a@1",
};
},
kind: "static",
models: async () => [],
};
const denseRepository = createRecordingProjectionRepository();
const ftsRepository = createRecordingProjectionRepository();
const node = knowledgeNode({
metadata: {
chunkIndex: 0,
semanticChunking: {
section: {
path: ["Safety", "Tunnel"],
summary: "Checks excavation support and edge protection.",
},
},
},
sourceLocation: {
endOffset: 12,
sectionPath: ["Safety", "Tunnel", "Initial support"],
startOffset: 0,
},
text: "Verify the current work face.",
});
await createDenseVectorProjectionBuilder({
embeddings,
maxBatchSize: 1,
projections: denseRepository.repository,
}).build({ model: "model-a", nodes: [node], projectionVersion: 1 });
const [fts] = await createFtsProjectionBuilder({
maxBatchSize: 1,
projections: ftsRepository.repository,
}).build({ nodes: [node], projectionVersion: 1 });
expect(embedCalls[0]?.texts).toEqual([
"Safety > Tunnel > Initial support\n\nChecks excavation support and edge protection.\n\nVerify the current work face.",
]);
expect(fts?.metadata.ftsText).toBe(
"safety tunnel initial support checks excavation support and edge protection verify the current work face",
);
expect(node.text).toBe("Verify the current work face.");
});
it("skips FTS projections for nodes without searchable text", async () => {
const { created, repository } = createRecordingProjectionRepository();
const builder = createFtsProjectionBuilder({

View File

@ -29,6 +29,7 @@ import {
assertObservedEmbeddingDimension,
} from "./knowledge-space-embedding-resolver";
import { normalizeMixedLanguageFtsText } from "./retrieval-text-utils";
import { TEXT_INDEXING_STRATEGY } from "./text-indexing-strategy";
export interface BuildDenseVectorProjectionInput {
/** Immutable profile captured by a compilation/profile-migration attempt. */
@ -258,6 +259,7 @@ export function createDenseVectorProjectionBuilder({
})
: new Map<string, IndexProjection>();
const nodesToEmbed = parsedNodes.filter((node) => !reusableByNodeId.has(node.id));
const embeddingTexts = nodesToEmbed.map(textIndexContentForNode);
if (nodesToEmbed.length === 0) {
recordIngestionModelCallMetric(metrics, {
cacheHits: parsedNodes.length,
@ -273,8 +275,8 @@ export function createDenseVectorProjectionBuilder({
);
}
modelBudget?.reserve({
estimatedTokens: nodesToEmbed.reduce(
(total, node) => total + estimateDocumentModelTokens(node.text),
estimatedTokens: embeddingTexts.reduce(
(total, text) => total + estimateDocumentModelTokens(text),
0,
),
itemCount: nodesToEmbed.length,
@ -287,7 +289,7 @@ export function createDenseVectorProjectionBuilder({
inputType: "search_document",
model: resolvedEmbedding?.model ?? model,
...(signal ? { signal } : {}),
texts: nodesToEmbed.map((node) => node.text),
texts: embeddingTexts,
...(tenantId ? { tenantId } : {}),
});
} catch (error) {
@ -373,6 +375,7 @@ export function createDenseVectorProjectionBuilder({
dimension: responseDimension,
documentAssetId: node.documentAssetId,
embeddingProvider: result.metadata.provider,
indexingStrategy: TEXT_INDEXING_STRATEGY,
embeddingModel: result.model,
...(resolvedEmbedding
? {
@ -457,7 +460,8 @@ async function loadReusableDenseProjections({
projection.type !== "dense-vector" ||
projection.metadata.artifactHash !== node.artifactHash ||
projection.metadata.documentAssetId !== node.documentAssetId ||
projection.metadata.parseArtifactId !== node.parseArtifactId
projection.metadata.parseArtifactId !== node.parseArtifactId ||
projection.metadata.indexingStrategy !== TEXT_INDEXING_STRATEGY
) {
throw new Error(
`Persisted generation-scoped dense projection id=${projection.id} cannot be reused`,
@ -498,10 +502,11 @@ export function createFtsProjectionBuilder({
const parsedNodes = nodes.map((node) => cloneKnowledgeNode(KnowledgeNodeSchema.parse(node)));
const ftsProjections = parsedNodes.flatMap((node) => {
const ftsText = normalizeMixedLanguageFtsText(node.text);
if (!ftsText) {
const sourceFtsText = normalizeMixedLanguageFtsText(node.text);
if (!sourceFtsText) {
return [];
}
const ftsText = normalizeMixedLanguageFtsText(textIndexContentForNode(node));
return [
IndexProjectionSchema.parse({
@ -520,6 +525,7 @@ export function createFtsProjectionBuilder({
documentAssetId: node.documentAssetId,
ftsLanguageStrategy: "mixed-cjk-latin-v1",
ftsText,
indexingStrategy: TEXT_INDEXING_STRATEGY,
...multimodalProjectionMetadata(node),
parseArtifactId: node.parseArtifactId,
parser: "database-fts",
@ -564,7 +570,9 @@ export function createFtsProjectionBuilder({
projection.type !== "fts" ||
projection.metadata.artifactHash !== incoming.metadata.artifactHash ||
projection.metadata.documentAssetId !== incoming.metadata.documentAssetId ||
projection.metadata.parseArtifactId !== incoming.metadata.parseArtifactId
projection.metadata.parseArtifactId !== incoming.metadata.parseArtifactId ||
projection.metadata.indexingStrategy !== TEXT_INDEXING_STRATEGY ||
projection.metadata.ftsText !== incoming.metadata.ftsText
) {
throw new Error(
`Persisted generation-scoped FTS projection id=${projection.id} cannot be reused`,
@ -594,6 +602,28 @@ export function createFtsProjectionBuilder({
};
}
/**
* Add trusted semantic navigation context to search indexes without changing the cited source
* text stored on the knowledge node. Table rows and list continuations commonly omit their parent
* heading, so indexing only `node.text` makes them impossible to recall from a heading-led query.
*/
function textIndexContentForNode(node: KnowledgeNode): string {
const sectionPath = node.sourceLocation.sectionPath
.map((segment) => segment.trim())
.filter(Boolean)
.join(" > ");
const semanticChunking = isPlainObject(node.metadata.semanticChunking)
? node.metadata.semanticChunking
: undefined;
const section =
semanticChunking && isPlainObject(semanticChunking.section)
? semanticChunking.section
: undefined;
const summary = section && typeof section.summary === "string" ? section.summary.trim() : "";
return [sectionPath, summary, node.text.trim()].filter(Boolean).join("\n\n");
}
export function createVisualEmbeddingProjectionBuilder({
generateId,
maxBatchSize,

View File

@ -303,7 +303,7 @@ describe("LLM semantic chunker", () => {
completed: true,
entityCount: 2,
model: "reasoner-model",
promptVersion: "semantic-chunking-v5",
promptVersion: "semantic-chunking-v6",
},
relationExtraction: { completed: true, relationCount: 1 },
semanticChunking: {
@ -353,6 +353,9 @@ describe("LLM semantic chunker", () => {
expect(provider.calls[0]?.messages[0]?.content).toContain(
"prefer natural topic boundaries over filling chunks",
);
expect(provider.calls[0]?.messages[0]?.content).toContain(
"keep the complete list together under its heading",
);
});
it("hard-splits an overlong sentence by Unicode grapheme without overlap", async () => {
@ -557,7 +560,7 @@ describe("LLM semantic chunker", () => {
windowPlanning: {
atomicDocument: false,
sourceSectionPathCount: 2,
version: "v5",
version: "v6",
},
});
});
@ -633,13 +636,61 @@ describe("LLM semantic chunker", () => {
parseArtifact.elements.map((element) => element.text?.trim() ?? "").join("\n"),
);
expect(nodes[0]?.metadata.semanticChunking).toMatchObject({
windowPlanning: { version: "v5" },
windowPlanning: { version: "v6" },
});
expect(v2Nodes[0]?.metadata.semanticChunking).toMatchObject({
windowPlanning: { version: "v2" },
});
});
it("extends a fixed core boundary to keep a numbered list in one window", async () => {
const parseArtifact = artifact([
...Array.from({ length: 24 }, (_, index) => ({
id: `preamble-${index}`,
metadata: {},
sectionPath: ["Preamble"],
text: `Preamble ${index}.`,
type: "paragraph" as const,
})),
{
id: "list-heading",
metadata: {},
sectionPath: ["Negative list"],
text: "施工企业安全穿透式管理负面清单",
type: "paragraph" as const,
},
...Array.from({ length: 9 }, (_, index) => ({
id: `list-item-${index + 1}`,
metadata: {},
sectionPath: ["Negative list"],
text: `${index + 1}. 第 ${index + 1} 项负面情形。`,
type: "paragraph" as const,
})),
]);
const provider = new ScriptedProvider([echoEachUnit]);
const nodes = await createLlmSemanticChunker({
reasoningProviderFactory: () => provider,
}).chunk({
knowledgeSpaceId: KNOWLEDGE_SPACE_ID,
parseArtifact,
retrievalProfile: profile(),
});
expect(preflightLlmSemanticWindows({ parseArtifact })).toEqual({
maximumWindowCount: 1,
unitCount: 43,
});
expect(provider.calls).toHaveLength(1);
const prompt = JSON.parse(
provider.calls[0]?.messages.find((message) => message.role === "user")?.content ?? "{}",
) as PromptPayload;
expect(prompt.units).toHaveLength(43);
expect(prompt.units.at(-1)?.text).toBe("第 9 项负面情形。");
expect(nodes.map((node) => node.text).join("")).toBe(
parseArtifact.elements.map((element) => element.text?.trim() ?? "").join(""),
);
});
it("runs fixed-core semantic windows concurrently while preserving document order", async () => {
const parseArtifact = artifact(
Array.from({ length: 96 }, (_, index) => ({
@ -870,7 +921,7 @@ describe("LLM semantic chunker", () => {
windowPlanning: {
atomicDocument: true,
sourceSectionPathCount: 1,
version: "v5",
version: "v6",
},
});
expect(nodes[0]?.metadata.extractedEntities).toEqual([
@ -2351,6 +2402,18 @@ describe("LLM semantic chunker", () => {
expect(() =>
createLlmSemanticChunker({ reasoningProviderFactory: factory, temperature: -1 }),
).toThrow("temperature must be non-negative");
expect(() =>
createLlmSemanticChunker({
maxProviderOutputRetries: 4,
reasoningProviderFactory: factory,
}),
).toThrow("maxProviderOutputRetries must be at most 3");
expect(() =>
createLlmSemanticChunker({
maxProviderOutputRetries: -1,
reasoningProviderFactory: factory,
}),
).toThrow("maxProviderOutputRetries must be a non-negative integer");
for (const [name, options] of [
["maxEntitiesPerChunk", { maxEntitiesPerChunk: 0 }],
["maxNodes", { maxNodes: 0 }],
@ -2836,6 +2899,29 @@ describe("LLM semantic chunker", () => {
).rejects.toThrow("contiguously without gaps or overlap");
});
it("retries one invalid provider response before committing the window", async () => {
const provider = new ScriptedProvider([() => ({ chunks: "invalid" }), echoWholeWindow]);
const nodes = await createLlmSemanticChunker({
maxProviderOutputRetries: 1,
reasoningProviderFactory: () => provider,
}).chunk({
knowledgeSpaceId: KNOWLEDGE_SPACE_ID,
parseArtifact: artifact([
{
id: "retry-provider-output",
metadata: {},
sectionPath: [],
text: "Alpha. Beta.",
type: "paragraph",
},
]),
retrievalProfile: profile(),
});
expect(nodes).toHaveLength(1);
expect(provider.calls).toHaveLength(2);
});
it("accepts JSON wrapped in provider prose but strictly caps joint extraction arrays", async () => {
const input = {
knowledgeSpaceId: KNOWLEDGE_SPACE_ID,

View File

@ -62,14 +62,16 @@ const DEFAULT_MAX_ENTITIES_PER_CHUNK = 100;
const DEFAULT_MAX_RELATIONS_PER_CHUNK = 100;
const DEFAULT_MAX_OUTPUT_TOKENS = 6_000;
const DEFAULT_MAX_RESPONSE_CHARS = 1_000_000;
const DEFAULT_PROMPT_VERSION = "semantic-chunking-v5";
const DEFAULT_PROMPT_VERSION = "semantic-chunking-v6";
const SEMANTIC_CHUNKING_V2_PROMPT_VERSION = "semantic-chunking-v2";
const SEMANTIC_CHUNKING_V3_PROMPT_VERSION = "semantic-chunking-v3";
const SEMANTIC_CHUNKING_V4_PROMPT_VERSION = "semantic-chunking-v4";
const SEMANTIC_CHUNKING_V5_PROMPT_VERSION = "semantic-chunking-v5";
const V3_MAX_CORE_UNITS_PER_WINDOW = 32;
const V3_MAX_LOOK_AHEAD_UNITS_PER_WINDOW = 8;
const DEFAULT_MAX_CONCURRENT_WINDOWS = 4;
const MAX_CONCURRENT_WINDOWS = 32;
const MAX_PROVIDER_OUTPUT_RETRIES = 3;
const SEMANTIC_CHUNKING_STRATEGY = "llm-semantic-v1";
const SEMANTIC_CHUNKING_SCHEMA_VERSION = 1;
/**
@ -152,6 +154,7 @@ export interface LlmSemanticChunkerOptions {
readonly maxEntitiesPerChunk?: number | undefined;
readonly maxNodes?: number | undefined;
readonly maxOutputTokens?: number | undefined;
readonly maxProviderOutputRetries?: number | undefined;
readonly maxRelationsPerChunk?: number | undefined;
readonly maxResponseChars?: number | undefined;
readonly maxWindowChars?: number | undefined;
@ -310,7 +313,7 @@ interface SemanticWindowTableSchema {
readonly sourceElementId: string;
}
type SemanticWindowPlanningVersion = "v1" | "v2" | "v3" | "v4" | "v5";
type SemanticWindowPlanningVersion = "v1" | "v2" | "v3" | "v4" | "v5" | "v6";
interface SemanticWindowPlanningPolicy {
readonly atomicDocument: boolean;
@ -395,6 +398,7 @@ export function createLlmSemanticChunker({
maxEntitiesPerChunk = DEFAULT_MAX_ENTITIES_PER_CHUNK,
maxNodes = DEFAULT_MAX_NODES,
maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS,
maxProviderOutputRetries = 0,
maxRelationsPerChunk = DEFAULT_MAX_RELATIONS_PER_CHUNK,
maxResponseChars = DEFAULT_MAX_RESPONSE_CHARS,
maxWindowChars = DEFAULT_MAX_WINDOW_CHARS,
@ -410,6 +414,7 @@ export function createLlmSemanticChunker({
validatePositiveInteger("maxEntitiesPerChunk", maxEntitiesPerChunk);
validatePositiveInteger("maxNodes", maxNodes);
validatePositiveInteger("maxOutputTokens", maxOutputTokens);
validateNonnegativeInteger("maxProviderOutputRetries", maxProviderOutputRetries);
validatePositiveInteger("maxRelationsPerChunk", maxRelationsPerChunk);
validatePositiveInteger("maxResponseChars", maxResponseChars);
validatePositiveInteger("maxWindowChars", maxWindowChars);
@ -421,6 +426,11 @@ export function createLlmSemanticChunker({
`LLM semantic chunking maxConcurrentWindows must be at most ${MAX_CONCURRENT_WINDOWS}`,
);
}
if (maxProviderOutputRetries > MAX_PROVIDER_OUTPUT_RETRIES) {
throw new Error(
`LLM semantic chunking maxProviderOutputRetries must be at most ${MAX_PROVIDER_OUTPUT_RETRIES}`,
);
}
if (!promptVersion.trim()) {
throw new Error("LLM semantic chunking promptVersion is required");
}
@ -479,7 +489,7 @@ export function createLlmSemanticChunker({
let nextUnitIndex = 0;
let windowIndex = 0;
const processWindow = async (window: SemanticWindow) => {
const processWindowAttempt = async (window: SemanticWindow, retryCount: number) => {
input.signal?.throwIfAborted();
const messages = semanticChunkingMessages({
enableGraph: input.enableGraph !== false,
@ -597,7 +607,7 @@ export function createLlmSemanticChunker({
itemCount: window.units.length,
outcome: "succeeded",
providerCalls: checkpointHit ? 0 : 1,
retries: 0,
retries: retryCount,
stage: "semantic-chunking",
...(checkpointHit ? {} : ingestionModelUsageFromMetadata(resolvedCompletion.metadata)),
});
@ -609,7 +619,7 @@ export function createLlmSemanticChunker({
itemCount: window.units.length,
outcome: "failed",
providerCalls: checkpointHit ? 0 : 1,
retries: 0,
retries: retryCount,
stage: "semantic-chunking",
...(checkpointHit || !completion
? {}
@ -619,6 +629,23 @@ export function createLlmSemanticChunker({
}
};
const processWindow = async (window: SemanticWindow) => {
let retryCount = 0;
while (true) {
try {
return await processWindowAttempt(window, retryCount);
} catch (error) {
if (
retryCount >= maxProviderOutputRetries ||
!isRetryableSemanticProviderOutputError(error)
) {
throw error;
}
retryCount += 1;
}
}
};
const appendProcessedWindow = (
processed: Awaited<ReturnType<typeof processWindow>>,
): void => {
@ -1805,7 +1832,7 @@ function semanticRanges(
element: MaterializedElement,
planningVersion: SemanticWindowPlanningVersion,
): SemanticRange[] {
if (planningVersion === "v5" && element.elementType === "table") {
if ((planningVersion === "v5" || planningVersion === "v6") && element.elementType === "table") {
return semanticTableRanges(element);
}
if (element.elementType !== "paragraph" && element.elementType !== "list") {
@ -2112,7 +2139,8 @@ function resolveSemanticWindowPlanningPolicy({
}
function semanticWindowPlanningVersion(promptVersion: string): SemanticWindowPlanningVersion {
if (promptVersion === DEFAULT_PROMPT_VERSION) return "v5";
if (promptVersion === DEFAULT_PROMPT_VERSION) return "v6";
if (promptVersion === SEMANTIC_CHUNKING_V5_PROMPT_VERSION) return "v5";
if (promptVersion === SEMANTIC_CHUNKING_V4_PROMPT_VERSION) return "v4";
if (promptVersion === SEMANTIC_CHUNKING_V3_PROMPT_VERSION) return "v3";
if (promptVersion === SEMANTIC_CHUNKING_V2_PROMPT_VERSION) return "v2";
@ -2120,11 +2148,11 @@ function semanticWindowPlanningVersion(promptVersion: string): SemanticWindowPla
}
function usesBoundedCoreUnits(version: SemanticWindowPlanningVersion): boolean {
return version === "v3" || version === "v4" || version === "v5";
return version === "v3" || version === "v4" || version === "v5" || version === "v6";
}
function usesFixedCoreBoundary(version: SemanticWindowPlanningVersion): boolean {
return version === "v4" || version === "v5";
return version === "v4" || version === "v5" || version === "v6";
}
function materializeSemanticWindow({
@ -2152,13 +2180,14 @@ function materializeSemanticWindow({
const coreUnits: AtomicUnit[] = [];
let cursor = startUnitIndex;
while (cursor < units.length) {
const candidate = units[cursor] as AtomicUnit;
if (
usesBoundedCoreUnits(planningPolicy.version) &&
coreUnits.length >= V3_MAX_CORE_UNITS_PER_WINDOW
coreUnits.length >= V3_MAX_CORE_UNITS_PER_WINDOW &&
!(planningPolicy.version === "v6" && continuesNumberedList(coreUnits.at(-1), candidate))
) {
break;
}
const candidate = units[cursor] as AtomicUnit;
if (planningPolicy.version === "v1" && !isLegacyWindowCompatible(first, candidate)) {
break;
}
@ -2231,6 +2260,25 @@ function materializeSemanticWindow({
};
}
function continuesNumberedList(previous: AtomicUnit | undefined, candidate: AtomicUnit): boolean {
if (!previous) return false;
if (previous.sourceElement.elementId === candidate.sourceElement.elementId) return true;
const previousOrdinal = numberedListOrdinal(previous.sourceElement.text);
const candidateOrdinal = numberedListOrdinal(candidate.sourceElement.text);
return (
previousOrdinal !== undefined &&
candidateOrdinal !== undefined &&
candidateOrdinal === previousOrdinal + 1
);
}
function numberedListOrdinal(text: string): number | undefined {
const match = /^\s*(\d{1,4})\s*[.)]\s*/u.exec(text);
if (!match?.[1]) return undefined;
const ordinal = Number.parseInt(match[1], 10);
return Number.isSafeInteger(ordinal) ? ordinal : undefined;
}
function semanticPromptUnit(
unit: AtomicUnit,
planningVersion: SemanticWindowPlanningVersion,
@ -2261,7 +2309,7 @@ function semanticPromptUnit(
sourceSectionPath: [...unit.sectionPath],
}
: {}),
...(planningVersion === "v5" && unit.tableRecord
...((planningVersion === "v5" || planningVersion === "v6") && unit.tableRecord
? {
tableMode: unit.tableRecord.mode,
tableRecordCount: unit.tableRecord.count,
@ -2350,6 +2398,7 @@ function semanticChunkingMessages({
"Never emit a chunk that starts wholly in lookAheadUnits. Units not consumed from look-ahead will be reconsidered in the next request.",
]),
"Ranges may be smaller than the maximum; prefer natural topic boundaries over filling chunks.",
"When a contiguous numbered or bulleted list fits within the chunk limit, keep the complete list together under its heading.",
`Every range must contain at most ${maxChunkChars} Unicode graphemes including separators.`,
`Return at most ${maxEntitiesPerChunk} entities and ${maxRelationsPerChunk} relations per chunk.`,
"Allowed entity types: date, metric, organization, person, policy, product, term.",
@ -2365,7 +2414,7 @@ function semanticChunkingMessages({
: [
"A unit marked boundaryPolicy=isolated must occupy a chunk containing only units from that same table or image element.",
]),
...(window.planningVersion === "v5"
...(window.planningVersion === "v5" || window.planningVersion === "v6"
? [
"Table records carry tableMode/tableRecordIndex/tableRecordCount; resolve their bounded columns from tableSchemas by sourceElementId.",
"For tableMode=record-list or matrix, return exactly one chunk per table record; hard-split units with the same tableRecordIndex may stay together, but never combine different records.",
@ -2404,7 +2453,7 @@ function semanticChunkingMessages({
semanticPromptUnit(unit, window.planningVersion),
),
sectionPath: window.sectionPath,
...(window.planningVersion === "v5"
...(window.planningVersion === "v5" || window.planningVersion === "v6"
? {
tableSchemas: semanticWindowTableSchemas([...window.units, ...window.lookAheadUnits]),
}
@ -2620,6 +2669,14 @@ function parseSemanticChunkingOutput(text: string): LlmSemanticChunkingOutput {
}
}
function isRetryableSemanticProviderOutputError(error: unknown): boolean {
return (
error instanceof Error &&
(error.message === "LLM semantic chunking provider returned invalid JSON" ||
error.message === "LLM semantic chunking provider returned an invalid response schema")
);
}
function normalizeTableRecordBoundaries({
maxChunkChars,
output,
@ -2629,7 +2686,12 @@ function normalizeTableRecordBoundaries({
readonly output: LlmSemanticChunkingOutput;
readonly window: SemanticWindow;
}): LlmSemanticChunkingOutput {
if (window.planningVersion !== "v5" || window.atomicDocument) return output;
if (
(window.planningVersion !== "v5" && window.planningVersion !== "v6") ||
window.atomicDocument
) {
return output;
}
const eligibleUnits = [...window.units, ...window.lookAheadUnits];
const unitIndex = new Map(eligibleUnits.map((unit, index) => [unit.id, index]));

View File

@ -0,0 +1,5 @@
/**
* Bump when indexed text composition or normalization changes. The strategy participates in
* publication fingerprints and prevents reuse of projections built with an older composition.
*/
export const TEXT_INDEXING_STRATEGY = "section-context-v1";

View File

@ -135,6 +135,7 @@ export interface NativeParserOptions {
export interface UnstructuredParserClientOptions extends NativeParserOptions {
readonly apiKey?: string;
readonly defaultLanguage?: string;
readonly endpoint: string;
readonly fetch?: typeof fetch;
readonly maxResponseBytes?: number;
@ -303,6 +304,7 @@ export function createNativeStructuredDataParser(
export function createUnstructuredParserClient({
apiKey,
defaultLanguage,
endpoint,
fetch: fetchImpl = fetch,
maxConcurrency = defaultMaxConcurrency,
@ -323,9 +325,12 @@ export function createUnstructuredParserClient({
requestGate.run(async () => {
const deadline = createUnstructuredRequestDeadline(input.signal, requestTimeoutMs);
try {
const parserVersion = options.parserVersion ?? "unstructured@9";
const parserVersion = options.parserVersion ?? "unstructured@10";
const partitionStrategy = unstructuredPartitionStrategy(input);
const providerImageBlockTypes = unstructuredProviderImageBlockTypes(input);
const providerLanguage = unstructuredLanguage(
input.parserHints?.language ?? defaultLanguage,
);
assertInputBounds(input.body, options.maxInputBytes ?? defaultMaxInputBytes);
const response = await fetchWithRetries({
buildRequest: () => {
@ -337,6 +342,7 @@ export function createUnstructuredParserClient({
form.set("files", new File([fileBody], input.filename, { type: input.mimeType }));
form.set("coordinates", "true");
form.set("strategy", partitionStrategy);
if (providerLanguage) form.set("languages", providerLanguage);
if (providerImageBlockTypes.length > 0) {
for (const blockType of providerImageBlockTypes) {
form.append("extract_image_block_types", blockType);
@ -388,6 +394,7 @@ export function createUnstructuredParserClient({
artifactHashContext: unstructuredArtifactHashContext(input, {
partitionStrategy,
providerImageBlockTypes,
providerLanguage,
}),
elements,
input,
@ -436,6 +443,28 @@ function shouldRequestProviderImages(input: ParseDocumentInput): boolean {
return unstructuredProviderImageBlockTypes(input).length > 0;
}
function unstructuredLanguage(language: string | undefined): string | undefined {
const normalized = language?.trim().toLowerCase();
if (!normalized) return undefined;
const baseLanguage = normalized.split("-", 1)[0] ?? normalized;
return (
{
ar: "ara",
de: "deu",
en: "eng",
es: "spa",
fr: "fra",
hi: "hin",
ja: "jpn",
ko: "kor",
pt: "por",
ru: "rus",
zh: "zho",
}[baseLanguage] ?? normalized
);
}
function unstructuredProviderImageBlockTypes(
input: ParseDocumentInput,
): readonly ("Image" | "Table")[] {
@ -472,6 +501,7 @@ function unstructuredArtifactHashContext(
request: {
readonly partitionStrategy: "auto" | "fast" | "hi_res";
readonly providerImageBlockTypes: readonly ("Image" | "Table")[];
readonly providerLanguage?: string | undefined;
},
): string {
const hints = input.parserHints;
@ -491,6 +521,7 @@ function unstructuredArtifactHashContext(
coordinates: true,
imageBlockTypes: request.providerImageBlockTypes,
imagePayload: request.providerImageBlockTypes.length > 0,
language: request.providerLanguage ?? null,
strategy: request.partitionStrategy,
},
});
@ -2307,7 +2338,10 @@ function unstructuredElementsToElements(
const type = unstructuredType(sourceElement.type);
const tableProjection =
type === "table" ? unstructuredTableProjection(sourceElement.metadata) : undefined;
const text = tableProjection?.text ?? normalizeText(sourceElement.text ?? "");
const providerText = tableProjection?.text ?? normalizeText(sourceElement.text ?? "");
const text = hasChineseOcrLanguage(sourceElement.metadata)
? normalizeChineseOcrText(providerText)
: providerText;
if (!text && !hasUnstructuredVisualMetadata(sourceElement.metadata, type)) {
continue;
@ -2349,6 +2383,26 @@ function unstructuredElementsToElements(
return elements;
}
function hasChineseOcrLanguage(metadata: Readonly<Record<string, unknown>>): boolean {
const languages = metadata.languages;
return (
Array.isArray(languages) &&
languages.some(
(language) =>
typeof language === "string" &&
(language.trim().toLowerCase() === "zho" ||
language.trim().toLowerCase().startsWith("zh-")),
)
);
}
function normalizeChineseOcrText(text: string): string {
return text.replace(
/(?<=[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff])[\t \u3000]+(?=[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff])/gu,
"",
);
}
function unstructuredCategoryDepth(
metadata: Readonly<Record<string, unknown>>,
): number | undefined {

View File

@ -1342,7 +1342,7 @@ describe("parser adapters", () => {
metadata: {
filename: "report.pdf",
mimeType: "application/pdf",
parserVersion: "unstructured@9",
parserVersion: "unstructured@10",
},
parser: "unstructured",
version: 1,
@ -1410,6 +1410,54 @@ describe("parser adapters", () => {
]);
});
it("normalizes spacing between Chinese OCR characters without rewriting words", async () => {
const parser = createUnstructuredParserClient({
endpoint: "https://unstructured.example.test",
fetch: async () =>
new Response(
JSON.stringify([
{
metadata: { languages: ["zho"], page_number: 1 },
text: "随 道 工 程 。源 道 初 期 支 护 与 开 挖 作 业",
type: "NarrativeText",
},
{
metadata: {
languages: ["zho"],
page_number: 1,
text_as_html: "<table><tr><td>施 工 条 件</td><td>临 时 用 电</td></tr></table>",
},
text: "施 工 条 件 临 时 用 电",
type: "Table",
},
{
metadata: { languages: ["zho"], page_number: 1 },
text: "随着道路工程推进",
type: "NarrativeText",
},
]),
{ headers: { "content-type": "application/json" }, status: 200 },
),
generateId: () => "018f0d60-7a49-7cc2-9c1b-5b36f18f2c51",
now: () => createdAt,
});
const artifact = await parser.parse({
body: new Uint8Array([1, 2, 3]),
documentAssetId,
filename: "tunnel.pdf",
mimeType: "application/pdf",
version: 1,
});
expect(artifact.elements.map((element) => element.text)).toEqual([
"随道工程 。源道初期支护与开挖作业",
"column_1: 施工条件 | column_2: 临时用电",
"随着道路工程推进",
]);
expect(artifact.metadata.parserVersion).toBe("unstructured@10");
});
it("projects an Unstructured spreadsheet table into independently retrievable records", async () => {
const parser = createUnstructuredParserClient({
endpoint: "https://unstructured.example.test",
@ -1539,7 +1587,7 @@ describe("parser adapters", () => {
version: 1,
}),
).resolves.toMatchObject({
metadata: { parserVersion: "unstructured@9" },
metadata: { parserVersion: "unstructured@10" },
parser: "unstructured",
});
},
@ -1632,6 +1680,7 @@ describe("parser adapters", () => {
});
const parseWithHints = (parserHints: {
readonly imagesHandledExternally?: boolean;
readonly language?: string;
readonly layoutComplexity?: "complex" | "simple";
readonly requiresImages?: boolean;
readonly requiresOcr?: boolean;
@ -1646,12 +1695,13 @@ describe("parser adapters", () => {
version: 1,
});
const [fast, ocr, tables, providerImages, externalImages] = await Promise.all([
const [fast, ocr, tables, providerImages, externalImages, chinese] = await Promise.all([
parseWithHints({ layoutComplexity: "simple" }),
parseWithHints({ requiresOcr: true }),
parseWithHints({ requiresTables: true }),
parseWithHints({ requiresImages: true }),
parseWithHints({ imagesHandledExternally: true, requiresImages: true }),
parseWithHints({ language: "zh-CN" }),
]);
expect(
@ -1661,8 +1711,9 @@ describe("parser adapters", () => {
tables.artifactHash,
providerImages.artifactHash,
externalImages.artifactHash,
chinese.artifactHash,
]).size,
).toBe(5);
).toBe(6);
});
it.each([