Files
simstudioai--sim/apps/sim/lib/chunkers/docs-chunker.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

388 lines
11 KiB
TypeScript

import fs from 'fs/promises'
import path from 'path'
import { createLogger } from '@sim/logger'
import { TextChunker } from '@/lib/chunkers/text-chunker'
import type { DocChunk, DocsChunkerOptions } from '@/lib/chunkers/types'
import { estimateTokens } from '@/lib/chunkers/utils'
import { generateEmbeddings, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings'
interface HeaderInfo {
level: number
text: string
anchor?: string
position?: number
}
interface Frontmatter {
title?: string
description?: string
[key: string]: unknown
}
const logger = createLogger('DocsChunker')
export class DocsChunker {
private readonly textChunker: TextChunker
private readonly baseUrl: string
private readonly chunkSize: number
constructor(options: DocsChunkerOptions = {}) {
this.chunkSize = options.chunkSize ?? 300
this.textChunker = new TextChunker({
chunkSize: this.chunkSize,
minCharactersPerChunk: options.minCharactersPerChunk ?? 1,
chunkOverlap: options.chunkOverlap ?? 50,
})
this.baseUrl = options.baseUrl ?? 'https://docs.sim.ai'
}
async chunkAllDocs(docsPath: string): Promise<DocChunk[]> {
const allChunks: DocChunk[] = []
try {
const mdxFiles = await this.findMdxFiles(docsPath)
logger.info(`Found ${mdxFiles.length} .mdx files to process`)
for (const filePath of mdxFiles) {
try {
const chunks = await this.chunkMdxFile(filePath, docsPath)
allChunks.push(...chunks)
logger.info(`Processed ${filePath}: ${chunks.length} chunks`)
} catch (error) {
logger.error(`Error processing ${filePath}:`, error)
}
}
logger.info(`Total chunks generated: ${allChunks.length}`)
return allChunks
} catch (error) {
logger.error('Error processing docs:', error)
throw error
}
}
async chunkMdxFile(filePath: string, basePath: string): Promise<DocChunk[]> {
const content = await fs.readFile(filePath, 'utf-8')
const relativePath = path.relative(basePath, filePath)
const { data: frontmatter, content: markdownContent } = this.parseFrontmatter(content)
const documentUrl = this.generateDocumentUrl(relativePath)
const { chunks: textChunks, cleanedContent } = await this.splitContent(markdownContent)
const headers = this.extractHeaders(cleanedContent)
logger.info(`Generating embeddings for ${textChunks.length} chunks in ${relativePath}`)
const embeddingModel = getConfiguredEmbeddingModel()
const embeddings: number[][] =
textChunks.length > 0 ? (await generateEmbeddings(textChunks, embeddingModel)).embeddings : []
const chunks: DocChunk[] = []
let currentPosition = 0
for (let i = 0; i < textChunks.length; i++) {
const chunkText = textChunks[i]
const chunkStart = currentPosition
const chunkEnd = currentPosition + chunkText.length
const relevantHeader = this.findRelevantHeader(headers, chunkStart)
const chunk: DocChunk = {
text: chunkText,
tokenCount: estimateTokens(chunkText),
sourceDocument: relativePath,
headerLink: relevantHeader ? `${documentUrl}#${relevantHeader.anchor}` : documentUrl,
headerText: relevantHeader?.text || frontmatter.title || 'Document Root',
headerLevel: relevantHeader?.level || 1,
embedding: embeddings[i] || [],
embeddingModel,
metadata: {
startIndex: chunkStart,
endIndex: chunkEnd,
title: frontmatter.title,
},
}
chunks.push(chunk)
currentPosition = chunkEnd
}
return chunks
}
private async findMdxFiles(dirPath: string): Promise<string[]> {
const files: string[] = []
const entries = await fs.readdir(dirPath, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name)
if (entry.isDirectory()) {
const subFiles = await this.findMdxFiles(fullPath)
files.push(...subFiles)
} else if (entry.isFile() && entry.name.endsWith('.mdx')) {
files.push(fullPath)
}
}
return files
}
private extractHeaders(content: string): HeaderInfo[] {
const headers: HeaderInfo[] = []
const headerRegex = /^(#{1,6})\s+(.+)$/gm
let match
while ((match = headerRegex.exec(content)) !== null) {
const level = match[1].length
const text = match[2].trim()
const anchor = this.generateAnchor(text)
headers.push({
text,
level,
anchor,
position: match.index,
})
}
return headers
}
private generateAnchor(headerText: string): string {
return headerText
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
/** index.mdx files are served at the parent directory path */
private generateDocumentUrl(relativePath: string): string {
let urlPath = relativePath.replace(/\.mdx$/, '').replace(/\\/g, '/')
if (urlPath.endsWith('/index')) {
urlPath = urlPath.slice(0, -6)
} else if (urlPath === 'index') {
urlPath = ''
}
return `${this.baseUrl}/${urlPath}`
}
private findRelevantHeader(headers: HeaderInfo[], position: number): HeaderInfo | null {
if (headers.length === 0) return null
let relevantHeader: HeaderInfo | null = null
for (const header of headers) {
if (header.position !== undefined && header.position <= position) {
relevantHeader = header
} else {
break
}
}
return relevantHeader
}
/** Returns both chunks and cleaned content so header extraction uses aligned positions. */
private async splitContent(
content: string
): Promise<{ chunks: string[]; cleanedContent: string }> {
const cleanedContent = this.cleanContent(content)
const tableBoundaries = this.detectTableBoundaries(cleanedContent)
const chunks = await this.textChunker.chunk(cleanedContent)
const processedChunks = this.mergeTableChunks(
chunks.map((chunk) => chunk.text),
tableBoundaries,
cleanedContent
)
const finalChunks = this.enforceSizeLimit(processedChunks)
return { chunks: finalChunks, cleanedContent }
}
private cleanContent(content: string): string {
return content
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/^import\s+.*$/gm, '')
.replace(/^export\s+.*$/gm, '')
.replace(/<\/?[a-zA-Z][^>]*>/g, ' ')
.replace(/\{\/\*[\s\S]*?\*\/\}/g, ' ')
.replace(/\{[^{}]*\}/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.replace(/[ \t]{2,}/g, ' ')
.trim()
}
private parseFrontmatter(content: string): { data: Frontmatter; content: string } {
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/
const match = content.match(frontmatterRegex)
if (!match) {
return { data: {}, content }
}
const [, frontmatterText, markdownContent] = match
const data: Frontmatter = {}
const lines = frontmatterText.split('\n')
for (const line of lines) {
const colonIndex = line.indexOf(':')
if (colonIndex > 0) {
const key = line.slice(0, colonIndex).trim()
const value = line
.slice(colonIndex + 1)
.trim()
.replace(/^['"]|['"]$/g, '')
data[key] = value
}
}
return { data, content: markdownContent }
}
/** Detects table boundaries to avoid splitting tables across chunks. */
private detectTableBoundaries(content: string): { start: number; end: number }[] {
const tables: { start: number; end: number }[] = []
const lines = content.split('\n')
let inTable = false
let inCodeBlock = false
let tableStart = -1
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim()
if (line.startsWith('```')) {
inCodeBlock = !inCodeBlock
continue
}
if (inCodeBlock) continue
if (line.includes('|') && line.split('|').length >= 3 && !inTable) {
const nextLine = lines[i + 1]?.trim()
if (nextLine?.includes('|') && nextLine.includes('-')) {
inTable = true
tableStart = i
}
} else if (inTable && (!line.includes('|') || line === '' || line.startsWith('#'))) {
tables.push({
start: this.getCharacterPosition(lines, tableStart),
end: this.getCharacterPosition(lines, i - 1) + (lines[i - 1]?.length ?? 0),
})
inTable = false
}
}
if (inTable && tableStart >= 0) {
tables.push({
start: this.getCharacterPosition(lines, tableStart),
end: content.length,
})
}
return tables
}
private getCharacterPosition(lines: string[], lineIndex: number): number {
return lines.slice(0, lineIndex).reduce((acc, line) => acc + line.length + 1, 0)
}
private mergeTableChunks(
chunks: string[],
tableBoundaries: { start: number; end: number }[],
originalContent: string
): string[] {
if (tableBoundaries.length === 0) {
return chunks
}
const mergedChunks: string[] = []
let currentPosition = 0
for (const chunk of chunks) {
const chunkStart = originalContent.indexOf(chunk, currentPosition)
if (chunkStart === -1) {
mergedChunks.push(chunk)
continue
}
const chunkEnd = chunkStart + chunk.length
const intersectsTable = tableBoundaries.some(
(table) =>
(chunkStart >= table.start && chunkStart <= table.end) ||
(chunkEnd >= table.start && chunkEnd <= table.end) ||
(chunkStart <= table.start && chunkEnd >= table.end)
)
if (intersectsTable) {
const affectedTables = tableBoundaries.filter(
(table) =>
(chunkStart >= table.start && chunkStart <= table.end) ||
(chunkEnd >= table.start && chunkEnd <= table.end) ||
(chunkStart <= table.start && chunkEnd >= table.end)
)
const minStart = Math.min(chunkStart, ...affectedTables.map((t) => t.start))
const maxEnd = Math.max(chunkEnd, ...affectedTables.map((t) => t.end))
const completeChunk = originalContent.slice(minStart, maxEnd).trim()
if (completeChunk && !mergedChunks.some((existing) => existing === completeChunk)) {
mergedChunks.push(completeChunk)
}
} else {
mergedChunks.push(chunk)
}
currentPosition = chunkEnd
}
return mergedChunks.filter((chunk) => chunk.length > 50)
}
private enforceSizeLimit(chunks: string[]): string[] {
const finalChunks: string[] = []
for (const chunk of chunks) {
const tokens = estimateTokens(chunk)
if (tokens <= this.chunkSize) {
finalChunks.push(chunk)
} else {
const lines = chunk.split('\n')
let currentChunk = ''
for (const line of lines) {
const testChunk = currentChunk ? `${currentChunk}\n${line}` : line
if (estimateTokens(testChunk) <= this.chunkSize) {
currentChunk = testChunk
} else {
if (currentChunk.trim()) {
finalChunks.push(currentChunk.trim())
}
currentChunk = line
}
}
if (currentChunk.trim()) {
finalChunks.push(currentChunk.trim())
}
}
}
return finalChunks.filter((chunk) => chunk.trim().length > 100)
}
}