/** * Copyright (c) 2024-2026 Тарабанов Александр Викторович * All rights reserved. * * This software is proprietary and confidential. * Unauthorized copying, modification, or distribution is prohibited. * * For licensing inquiries: info@hb3-accelerator.com * Website: https://hb3-accelerator.com * GitHub: https://github.com/VC-HB3-Accelerator */ const ollamaConfig = require('./ollamaConfig'); const logger = require('../utils/logger'); const axios = require('axios'); /** * Сервис для интеллектуальной разбивки документов на смысловые части (Semantic Chunking) */ class SemanticChunkingService { constructor() { this.defaultMaxChunkSize = 1000; // Максимальный размер чанка в символах this.defaultOverlap = 200; // Перекрытие между чанками в символах this.minChunkSize = 100; // Минимальный размер чанка } /** * Разбить документ на смысловые чанки * @param {string} text - Текст документа * @param {Object} options - Опции разбивки * @param {number} options.maxChunkSize - Максимальный размер чанка * @param {number} options.overlap - Перекрытие между чанками * @param {boolean} options.useLLM - Использовать LLM для определения структуры * @returns {Promise} Массив чанков с метаданными */ async chunkDocument(text, options = {}) { if (!text || typeof text !== 'string' || text.trim().length === 0) { return []; } const { maxChunkSize = this.defaultMaxChunkSize, overlap = this.defaultOverlap, useLLM = true } = options; logger.info(`[SemanticChunking] Разбивка документа: длина=${text.length}, maxChunkSize=${maxChunkSize}, useLLM=${useLLM}`); try { // Если документ небольшой, возвращаем как один чанк if (text.length <= maxChunkSize) { return [{ text: text.trim(), index: 0, start: 0, end: text.length, metadata: { section: 'Документ', isComplete: true } }]; } let chunks = []; if (useLLM) { // Используем LLM для определения структуры chunks = await this.chunkWithLLM(text, maxChunkSize, overlap); } else { // Простая разбивка по параграфам и предложениям chunks = await this.chunkByStructure(text, maxChunkSize, overlap); } logger.info(`[SemanticChunking] Получено чанков: ${chunks.length}`); return chunks; } catch (error) { logger.error(`[SemanticChunking] Ошибка разбивки документа:`, error); // Fallback на простую разбивку return this.chunkByStructure(text, maxChunkSize, overlap); } } /** * Разбивка с использованием LLM для определения структуры */ async chunkWithLLM(text, maxChunkSize, overlap) { try { // Получаем конфигурацию Ollama const ollamaConfig_data = await ollamaConfig.getConfigAsync(); const baseUrl = ollamaConfig_data.baseUrl || 'http://ollama:11434'; const model = ollamaConfig_data.defaultModel || 'qwen2.5:7b'; // Если текст очень большой, анализируем первые 10000 символов для определения структуры const analysisText = text.length > 10000 ? text.substring(0, 10000) : text; const prompt = `Проанализируй структуру следующего документа и определи логические разделы. Документ: ${analysisText}${text.length > 10000 ? '\n\n[Документ продолжается...]' : ''} Верни JSON с разделами в следующем формате: { "sections": [ { "title": "Название раздела (например, 'Общие условия', 'Стоимость')", "start": позиция_начала_раздела_в_тексте, "end": позиция_конца_раздела_в_тексте, "text": "полный_текст_раздела" } ] } Важно: - Разделы должны быть логически завершенными (законченная мысль) - Если раздел слишком большой (больше ${maxChunkSize} символов), раздели его на подразделы - Не обрезай предложения посередине - Если не можешь определить структуру, верни один раздел с title: "Документ"`; const response = await axios.post(`${baseUrl}/api/chat`, { model: model, messages: [ { role: 'user', content: prompt } ], format: 'json', options: { temperature: 0.1, // Низкая температура для точности num_predict: 2000 } }, { timeout: 30000 }); const content = response.data.message?.content || response.data.response || ''; let structure; try { // Парсим JSON из ответа (может быть обернут в markdown) const jsonMatch = content.match(/\{[\s\S]*\}/); if (jsonMatch) { structure = JSON.parse(jsonMatch[0]); } else { structure = JSON.parse(content); } } catch (parseError) { logger.warn(`[SemanticChunking] Ошибка парсинга JSON от LLM, используем простую разбивку:`, parseError); return this.chunkByStructure(text, maxChunkSize, overlap); } // Формируем чанки из разделов const chunks = []; if (structure.sections && Array.isArray(structure.sections)) { for (let i = 0; i < structure.sections.length; i++) { const section = structure.sections[i]; const sectionText = section.text || text.substring(section.start || 0, section.end || text.length); // Если раздел большой, разбиваем его на подразделы if (sectionText.length > maxChunkSize) { const subChunks = this.splitLargeSection(sectionText, section.title || 'Раздел', maxChunkSize, overlap); chunks.push(...subChunks.map((chunk, idx) => ({ ...chunk, index: chunks.length + idx, metadata: { ...chunk.metadata, parentSection: section.title } }))); } else { chunks.push({ text: sectionText.trim(), index: chunks.length, start: section.start || 0, end: section.end || sectionText.length, metadata: { section: section.title || `Раздел ${i + 1}`, isComplete: true } }); } } } // Если LLM не определил структуру, используем простую разбивку if (chunks.length === 0) { return this.chunkByStructure(text, maxChunkSize, overlap); } return chunks; } catch (error) { logger.error(`[SemanticChunking] Ошибка LLM разбивки:`, error); // Fallback на простую разбивку return this.chunkByStructure(text, maxChunkSize, overlap); } } /** * Разбивка по структуре (параграфы, предложения) */ async chunkByStructure(text, maxChunkSize, overlap) { const chunks = []; // Разбиваем на параграфы (двойной перенос строки) const paragraphs = text.split(/\n\s*\n/).filter(p => p.trim().length > 0); let currentChunk = ''; let currentStart = 0; let chunkIndex = 0; for (let i = 0; i < paragraphs.length; i++) { const paragraph = paragraphs[i].trim(); // Если параграф сам по себе больше maxChunkSize, разбиваем его if (paragraph.length > maxChunkSize) { // Сохраняем текущий чанк, если есть if (currentChunk.length > 0) { chunks.push({ text: currentChunk.trim(), index: chunkIndex++, start: currentStart, end: currentStart + currentChunk.length, metadata: { section: `Параграф ${chunkIndex}`, isComplete: true } }); currentChunk = ''; } // Разбиваем большой параграф const subChunks = this.splitLargeSection(paragraph, `Параграф ${i + 1}`, maxChunkSize, overlap); chunks.push(...subChunks.map((chunk, idx) => ({ ...chunk, index: chunkIndex++, metadata: { ...chunk.metadata, parentParagraph: i + 1 } }))); continue; } // Проверяем, поместится ли параграф в текущий чанк const potentialChunk = currentChunk + (currentChunk ? '\n\n' : '') + paragraph; if (potentialChunk.length <= maxChunkSize) { // Добавляем параграф к текущему чанку if (currentChunk.length === 0) { currentStart = text.indexOf(paragraph); } currentChunk = potentialChunk; } else { // Сохраняем текущий чанк и начинаем новый if (currentChunk.length > 0) { chunks.push({ text: currentChunk.trim(), index: chunkIndex++, start: currentStart, end: currentStart + currentChunk.length, metadata: { section: `Параграф ${chunkIndex}`, isComplete: true } }); } // Начинаем новый чанк с текущего параграфа currentChunk = paragraph; currentStart = text.indexOf(paragraph); } } // Сохраняем последний чанк if (currentChunk.length > 0) { chunks.push({ text: currentChunk.trim(), index: chunkIndex, start: currentStart, end: currentStart + currentChunk.length, metadata: { section: `Параграф ${chunkIndex + 1}`, isComplete: true } }); } // Добавляем перекрытие (overlap) между чанками if (overlap > 0 && chunks.length > 1) { for (let i = 1; i < chunks.length; i++) { const prevChunk = chunks[i - 1]; const currentChunk = chunks[i]; // Берем последние N символов предыдущего чанка const overlapText = prevChunk.text.slice(-overlap); if (overlapText.trim().length > 0) { // Добавляем в начало текущего чанка currentChunk.text = overlapText + '\n\n' + currentChunk.text; currentChunk.metadata.hasOverlap = true; } } } return chunks; } /** * Разбить большой раздел на подразделы */ splitLargeSection(text, sectionTitle, maxChunkSize, overlap) { const chunks = []; // Разбиваем по предложениям const sentences = text.split(/(?<=[.!?])\s+/).filter(s => s.trim().length > 0); let currentChunk = ''; let chunkIndex = 0; for (const sentence of sentences) { const potentialChunk = currentChunk + (currentChunk ? ' ' : '') + sentence; if (potentialChunk.length <= maxChunkSize) { currentChunk = potentialChunk; } else { // Сохраняем текущий чанк if (currentChunk.length >= this.minChunkSize) { chunks.push({ text: currentChunk.trim(), index: chunkIndex++, metadata: { section: `${sectionTitle} - Часть ${chunkIndex}`, isComplete: false } }); } currentChunk = sentence; } } // Сохраняем последний чанк if (currentChunk.length >= this.minChunkSize) { chunks.push({ text: currentChunk.trim(), index: chunkIndex, metadata: { section: `${sectionTitle} - Часть ${chunkIndex + 1}`, isComplete: false } }); } return chunks; } } // Создаем singleton экземпляр const semanticChunkingService = new SemanticChunkingService(); module.exports = semanticChunkingService;