import OpenAI from 'openai';
import dotenv from 'dotenv';
dotenv.config();
export interface EmbeddingResult {
embedding: number[];
tokenCount: number;
}
export class EmbeddingService {
private openai: OpenAI;
private model: string;
private maxTokens: number;
constructor() {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error('OPENAI_API_KEY не найден в переменных окружения');
}
this.openai = new OpenAI({
apiKey,
});
this.model = 'text-embedding-3-small'; // Новейшая модель OpenAI
this.maxTokens = 8191; // Лимит для text-embedding-3-small
}
async generateEmbedding(text: string): Promise<EmbeddingResult> {
try {
console.log(`🧠 Генерация эмбеддинга для текста (${text.length} символов)`);
// Обрезаем текст если он слишком длинный
const truncatedText = this.truncateText(text, this.maxTokens);
const response = await this.openai.embeddings.create({
model: this.model,
input: truncatedText,
encoding_format: 'float',
});
const embedding = response.data[0].embedding;
const tokenCount = response.usage.total_tokens;
console.log(`✅ Эмбеддинг сгенерирован (${embedding.length} измерений, ${tokenCount} токенов)`);
return {
embedding,
tokenCount
};
} catch (error) {
console.error('Ошибка генерации эмбеддинга:', error);
throw new Error(`Ошибка генерации эмбеддинга: ${error}`);
}
}
async generateEmbeddings(texts: string[]): Promise<EmbeddingResult[]> {
try {
console.log(`🧠 Генерация эмбеддингов для ${texts.length} текстов`);
// Обрезаем тексты если нужно и фильтруем пустые
const truncatedTexts = texts
.map(text => this.truncateText(text, this.maxTokens))
.filter(text => text && text.trim().length > 0);
if (truncatedTexts.length === 0) {
console.log('⚠️ Нет валидных текстов для генерации эмбеддингов');
return [];
}
console.log(`📝 Генерируем эмбеддинги для ${truncatedTexts.length} валидных текстов`);
const response = await this.openai.embeddings.create({
model: this.model,
input: truncatedTexts,
encoding_format: 'float',
});
const results: EmbeddingResult[] = response.data.map(item => ({
embedding: item.embedding,
tokenCount: 0 // OpenAI не возвращает токены для batch запросов
}));
console.log(`✅ Сгенерировано ${results.length} эмбеддингов`);
return results;
} catch (error) {
console.error('Ошибка генерации эмбеддингов:', error);
throw new Error(`Ошибка генерации эмбеддингов: ${error}`);
}
}
calculateSimilarity(embedding1: number[], embedding2: number[]): number {
if (embedding1.length !== embedding2.length) {
throw new Error('Размеры эмбеддингов должны совпадать');
}
// Косинусное сходство
let dotProduct = 0;
let norm1 = 0;
let norm2 = 0;
for (let i = 0; i < embedding1.length; i++) {
dotProduct += embedding1[i] * embedding2[i];
norm1 += embedding1[i] * embedding1[i];
norm2 += embedding2[i] * embedding2[i];
}
norm1 = Math.sqrt(norm1);
norm2 = Math.sqrt(norm2);
if (norm1 === 0 || norm2 === 0) {
return 0;
}
return dotProduct / (norm1 * norm2);
}
findMostSimilar(
queryEmbedding: number[],
embeddings: Array<{ id: string | number; embedding: number[] }>,
topK: number = 5
): Array<{ id: string | number; similarity: number }> {
const similarities = embeddings.map(item => ({
id: item.id,
similarity: this.calculateSimilarity(queryEmbedding, item.embedding)
}));
// Сортируем по убыванию сходства
similarities.sort((a, b) => b.similarity - a.similarity);
return similarities.slice(0, topK);
}
private truncateText(text: string, maxTokens: number): string {
// Проверяем, что text не undefined и не null
if (!text || typeof text !== 'string') {
return '';
}
// Простая эвристика: ~4 символа на токен
const estimatedTokens = Math.ceil(text.length / 4);
if (estimatedTokens <= maxTokens) {
return text;
}
// Обрезаем до безопасного размера
const maxChars = maxTokens * 4;
return text.substring(0, maxChars);
}
async testConnection(): Promise<boolean> {
try {
const result = await this.generateEmbedding('test');
return result.embedding.length > 0;
} catch {
return false;
}
}
}