397 lines
17 KiB
Python
397 lines
17 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""
|
|||
|
|
Скрипт для обработки всех отелей:
|
|||
|
|
1. Создание chunks из hotel_website_processed
|
|||
|
|
2. Генерация эмбеддингов через BGE-M3 API
|
|||
|
|
3. Сохранение в hotel_website_chunks с metadata
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import psycopg2
|
|||
|
|
from urllib.parse import unquote
|
|||
|
|
import requests
|
|||
|
|
import json
|
|||
|
|
import time
|
|||
|
|
import logging
|
|||
|
|
from typing import List, Dict, Tuple
|
|||
|
|
import uuid
|
|||
|
|
|
|||
|
|
# Настройка логирования
|
|||
|
|
logging.basicConfig(
|
|||
|
|
level=logging.INFO,
|
|||
|
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
|||
|
|
handlers=[
|
|||
|
|
logging.FileHandler('embeddings_processing.log'),
|
|||
|
|
logging.StreamHandler()
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
# Конфигурация
|
|||
|
|
BGE_API_URL = "http://147.45.146.17:8002/embed"
|
|||
|
|
BGE_API_KEY = "22564b177aa73b6ac0b8642d7773350ff4c01d4983f028beff15ea247f09fa89"
|
|||
|
|
CHUNK_SIZE = 600
|
|||
|
|
CHUNK_OVERLAP = 100
|
|||
|
|
BATCH_SIZE = 8 # Размер батча для API (уменьшен из-за перегрузки)
|
|||
|
|
MAX_RETRIES = 3 # Количество попыток при ошибке
|
|||
|
|
|
|||
|
|
class EmbeddingProcessor:
|
|||
|
|
def __init__(self):
|
|||
|
|
self.conn = None
|
|||
|
|
self.cur = None
|
|||
|
|
self.connect_db()
|
|||
|
|
|
|||
|
|
def connect_db(self):
|
|||
|
|
"""Подключение к базе данных"""
|
|||
|
|
try:
|
|||
|
|
self.conn = psycopg2.connect(
|
|||
|
|
host='147.45.189.234',
|
|||
|
|
port=5432,
|
|||
|
|
database='default_db',
|
|||
|
|
user='gen_user',
|
|||
|
|
password=unquote('2~~9_%5EkVsU%3F2%5CS')
|
|||
|
|
)
|
|||
|
|
self.conn.autocommit = True
|
|||
|
|
self.cur = self.conn.cursor()
|
|||
|
|
logger.info("✅ Подключение к БД установлено")
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Ошибка подключения к БД: {e}")
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
def get_hotel_info(self, hotel_id: str) -> Dict:
|
|||
|
|
"""Получение информации об отеле из hotel_main"""
|
|||
|
|
try:
|
|||
|
|
self.cur.execute("""
|
|||
|
|
SELECT id, full_name, region_name
|
|||
|
|
FROM hotel_main
|
|||
|
|
WHERE id = %s;
|
|||
|
|
""", (hotel_id,))
|
|||
|
|
|
|||
|
|
result = self.cur.fetchone()
|
|||
|
|
if result:
|
|||
|
|
return {
|
|||
|
|
'hotel_id': result[0],
|
|||
|
|
'hotel_name': result[1],
|
|||
|
|
'region_name': result[2]
|
|||
|
|
}
|
|||
|
|
return None
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Ошибка получения информации об отеле {hotel_id}: {e}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def create_chunks_from_text(self, text: str, hotel_id: str, url: str, raw_page_id: int) -> List[Dict]:
|
|||
|
|
"""Создание chunks из текста"""
|
|||
|
|
if not text or len(text.strip()) < 50:
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
chunks = []
|
|||
|
|
start = 0
|
|||
|
|
|
|||
|
|
while start < len(text):
|
|||
|
|
end = start + CHUNK_SIZE
|
|||
|
|
chunk_text = text[start:end]
|
|||
|
|
|
|||
|
|
if end < len(text):
|
|||
|
|
# Ищем хорошую точку разрыва (конец предложения)
|
|||
|
|
last_period = chunk_text.rfind('.')
|
|||
|
|
last_newline = chunk_text.rfind('\n')
|
|||
|
|
break_point = max(last_period, last_newline)
|
|||
|
|
|
|||
|
|
if break_point > start + CHUNK_SIZE // 2:
|
|||
|
|
chunk_text = text[start:start + break_point + 1]
|
|||
|
|
end = start + break_point + 1
|
|||
|
|
|
|||
|
|
# Создаём metadata для chunk
|
|||
|
|
chunk_metadata = {
|
|||
|
|
'hotel_id': hotel_id,
|
|||
|
|
'url': url,
|
|||
|
|
'raw_page_id': raw_page_id,
|
|||
|
|
'chunk_start': start,
|
|||
|
|
'chunk_end': end,
|
|||
|
|
'chunk_length': len(chunk_text)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
chunks.append({
|
|||
|
|
'id': str(uuid.uuid4()),
|
|||
|
|
'text': chunk_text.strip(),
|
|||
|
|
'metadata': chunk_metadata
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# Следующий chunk с перекрытием
|
|||
|
|
start = end - CHUNK_OVERLAP
|
|||
|
|
if start >= len(text):
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
return chunks
|
|||
|
|
|
|||
|
|
def generate_embeddings_batch(self, texts: List[str]) -> List[List[float]]:
|
|||
|
|
"""Генерация эмбеддингов батчем через API с retry логикой"""
|
|||
|
|
for attempt in range(MAX_RETRIES):
|
|||
|
|
try:
|
|||
|
|
headers = {
|
|||
|
|
"X-API-Key": BGE_API_KEY,
|
|||
|
|
"Content-Type": "application/json"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
payload = {
|
|||
|
|
"text": texts
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Увеличиваем таймаут для больших батчей
|
|||
|
|
timeout = 120 if len(texts) > 20 else 60
|
|||
|
|
|
|||
|
|
response = requests.post(BGE_API_URL, json=payload, headers=headers, timeout=timeout)
|
|||
|
|
|
|||
|
|
if response.status_code == 200:
|
|||
|
|
result = response.json()
|
|||
|
|
embeddings = result.get('embeddings', [])
|
|||
|
|
if len(embeddings) == len(texts):
|
|||
|
|
return embeddings
|
|||
|
|
else:
|
|||
|
|
logger.warning(f"⚠️ Неполный ответ API: {len(embeddings)}/{len(texts)} эмбеддингов")
|
|||
|
|
if attempt < MAX_RETRIES - 1:
|
|||
|
|
logger.info(f"🔄 Повторная попытка {attempt + 2}/{MAX_RETRIES}")
|
|||
|
|
time.sleep(5) # Пауза перед повтором
|
|||
|
|
continue
|
|||
|
|
else:
|
|||
|
|
logger.error(f"❌ Ошибка API: {response.status_code} - {response.text}")
|
|||
|
|
if attempt < MAX_RETRIES - 1:
|
|||
|
|
logger.info(f"🔄 Повторная попытка {attempt + 2}/{MAX_RETRIES}")
|
|||
|
|
time.sleep(10) # Пауза перед повтором
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
except requests.exceptions.Timeout:
|
|||
|
|
logger.warning(f"⚠️ Таймаут API (попытка {attempt + 1}/{MAX_RETRIES})")
|
|||
|
|
if attempt < MAX_RETRIES - 1:
|
|||
|
|
logger.info(f"🔄 Повторная попытка {attempt + 2}/{MAX_RETRIES}")
|
|||
|
|
time.sleep(10) # Пауза перед повтором
|
|||
|
|
continue
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Ошибка генерации эмбеддингов (попытка {attempt + 1}/{MAX_RETRIES}): {e}")
|
|||
|
|
if attempt < MAX_RETRIES - 1:
|
|||
|
|
logger.info(f"🔄 Повторная попытка {attempt + 2}/{MAX_RETRIES}")
|
|||
|
|
time.sleep(5) # Пауза перед повтором
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
logger.error(f"❌ Не удалось получить эмбеддинги после {MAX_RETRIES} попыток")
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
def save_chunks_to_db(self, chunks: List[Dict], hotel_info: Dict):
|
|||
|
|
"""Сохранение chunks в базу данных"""
|
|||
|
|
try:
|
|||
|
|
# Разбиваем chunks на батчи для API
|
|||
|
|
all_embeddings = []
|
|||
|
|
|
|||
|
|
for i in range(0, len(chunks), BATCH_SIZE):
|
|||
|
|
batch_chunks = chunks[i:i + BATCH_SIZE]
|
|||
|
|
batch_texts = [chunk['text'] for chunk in batch_chunks]
|
|||
|
|
|
|||
|
|
logger.info(f" 🔄 Обрабатываем батч {i//BATCH_SIZE + 1}: {len(batch_texts)} chunks")
|
|||
|
|
|
|||
|
|
# Генерируем эмбеддинги для батча
|
|||
|
|
batch_embeddings = self.generate_embeddings_batch(batch_texts)
|
|||
|
|
|
|||
|
|
if len(batch_embeddings) == len(batch_texts):
|
|||
|
|
all_embeddings.extend(batch_embeddings)
|
|||
|
|
logger.info(f" ✅ Батч успешно обработан")
|
|||
|
|
else:
|
|||
|
|
logger.error(f" ❌ Ошибка в батче: {len(batch_embeddings)}/{len(batch_texts)} эмбеддингов")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
# Небольшая пауза между батчами
|
|||
|
|
if i + BATCH_SIZE < len(chunks):
|
|||
|
|
time.sleep(1)
|
|||
|
|
|
|||
|
|
if len(all_embeddings) != len(chunks):
|
|||
|
|
logger.error(f"❌ Количество эмбеддингов ({len(all_embeddings)}) не совпадает с количеством chunks ({len(chunks)})")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
# Обновляем metadata с информацией об отеле и сохраняем в БД
|
|||
|
|
for i, chunk in enumerate(chunks):
|
|||
|
|
chunk['metadata']['hotel_name'] = hotel_info['hotel_name']
|
|||
|
|
chunk['metadata']['region_name'] = hotel_info['region_name']
|
|||
|
|
|
|||
|
|
# Сохраняем в БД
|
|||
|
|
embedding_str = json.dumps(all_embeddings[i])
|
|||
|
|
|
|||
|
|
self.cur.execute("""
|
|||
|
|
INSERT INTO hotel_website_chunks (id, text, metadata, embedding)
|
|||
|
|
VALUES (%s, %s, %s, %s::vector)
|
|||
|
|
ON CONFLICT (id) DO UPDATE SET
|
|||
|
|
text = EXCLUDED.text,
|
|||
|
|
metadata = EXCLUDED.metadata,
|
|||
|
|
embedding = EXCLUDED.embedding;
|
|||
|
|
""", (
|
|||
|
|
chunk['id'],
|
|||
|
|
chunk['text'],
|
|||
|
|
json.dumps(chunk['metadata']),
|
|||
|
|
embedding_str
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
logger.info(f"✅ Сохранено {len(chunks)} chunks для отеля {hotel_info['hotel_name'][:50]}...")
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Ошибка сохранения chunks: {e}")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def process_hotel(self, hotel_id: str) -> bool:
|
|||
|
|
"""Обработка одного отеля"""
|
|||
|
|
try:
|
|||
|
|
# Получаем информацию об отеле
|
|||
|
|
hotel_info = self.get_hotel_info(hotel_id)
|
|||
|
|
if not hotel_info:
|
|||
|
|
logger.warning(f"⚠️ Отель {hotel_id} не найден в hotel_main")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
# Получаем страницы отеля
|
|||
|
|
self.cur.execute("""
|
|||
|
|
SELECT id, url, cleaned_text
|
|||
|
|
FROM hotel_website_processed
|
|||
|
|
WHERE hotel_id = %s
|
|||
|
|
AND cleaned_text IS NOT NULL
|
|||
|
|
AND LENGTH(cleaned_text) > 50
|
|||
|
|
ORDER BY id;
|
|||
|
|
""", (hotel_id,))
|
|||
|
|
|
|||
|
|
pages = self.cur.fetchall()
|
|||
|
|
logger.info(f"🏨 Обрабатываем отель: {hotel_info['hotel_name'][:50]}...")
|
|||
|
|
logger.info(f" 📄 Найдено {len(pages)} страниц")
|
|||
|
|
|
|||
|
|
total_chunks = 0
|
|||
|
|
|
|||
|
|
for page_id, url, text in pages:
|
|||
|
|
# Создаём chunks
|
|||
|
|
chunks = self.create_chunks_from_text(text, hotel_id, url, page_id)
|
|||
|
|
|
|||
|
|
if chunks:
|
|||
|
|
# Сохраняем chunks
|
|||
|
|
if self.save_chunks_to_db(chunks, hotel_info):
|
|||
|
|
total_chunks += len(chunks)
|
|||
|
|
logger.info(f" ✅ Страница {page_id}: {len(chunks)} chunks")
|
|||
|
|
else:
|
|||
|
|
logger.error(f" ❌ Ошибка сохранения chunks для страницы {page_id}")
|
|||
|
|
|
|||
|
|
logger.info(f"🎉 Отель {hotel_info['hotel_name'][:50]}... обработан: {total_chunks} chunks")
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Ошибка обработки отеля {hotel_id}: {e}")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def get_hotels_to_process(self) -> List[str]:
|
|||
|
|
"""Получение списка отелей для обработки"""
|
|||
|
|
try:
|
|||
|
|
# Получаем отели, которые есть в hotel_website_processed, но нет в chunks
|
|||
|
|
self.cur.execute("""
|
|||
|
|
SELECT DISTINCT p.hotel_id
|
|||
|
|
FROM hotel_website_processed p
|
|||
|
|
LEFT JOIN hotel_website_chunks c ON p.hotel_id::text = c.metadata->>'hotel_id'
|
|||
|
|
WHERE p.cleaned_text IS NOT NULL
|
|||
|
|
AND LENGTH(p.cleaned_text) > 50
|
|||
|
|
AND c.id IS NULL
|
|||
|
|
ORDER BY p.hotel_id;
|
|||
|
|
""")
|
|||
|
|
|
|||
|
|
hotels = [row[0] for row in self.cur.fetchall()]
|
|||
|
|
logger.info(f"📊 Найдено {len(hotels)} отелей для обработки")
|
|||
|
|
return hotels
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Ошибка получения списка отелей: {e}")
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
def get_processing_stats(self) -> Dict:
|
|||
|
|
"""Получение статистики обработки"""
|
|||
|
|
try:
|
|||
|
|
self.cur.execute("""
|
|||
|
|
SELECT
|
|||
|
|
COUNT(DISTINCT p.hotel_id) as total_hotels,
|
|||
|
|
COUNT(p.id) as total_pages,
|
|||
|
|
COUNT(DISTINCT c.metadata->>'hotel_id') as processed_hotels,
|
|||
|
|
COUNT(c.id) as total_chunks
|
|||
|
|
FROM hotel_website_processed p
|
|||
|
|
LEFT JOIN hotel_website_chunks c ON p.hotel_id::text = c.metadata->>'hotel_id'
|
|||
|
|
WHERE p.cleaned_text IS NOT NULL AND LENGTH(p.cleaned_text) > 50;
|
|||
|
|
""")
|
|||
|
|
|
|||
|
|
result = self.cur.fetchone()
|
|||
|
|
return {
|
|||
|
|
'total_hotels': result[0],
|
|||
|
|
'total_pages': result[1],
|
|||
|
|
'processed_hotels': result[2],
|
|||
|
|
'total_chunks': result[3]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Ошибка получения статистики: {e}")
|
|||
|
|
return {}
|
|||
|
|
|
|||
|
|
def close(self):
|
|||
|
|
"""Закрытие соединения с БД"""
|
|||
|
|
if self.cur:
|
|||
|
|
self.cur.close()
|
|||
|
|
if self.conn:
|
|||
|
|
self.conn.close()
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
"""Основная функция"""
|
|||
|
|
logger.info("🚀 Запуск обработки отелей для эмбеддингов")
|
|||
|
|
|
|||
|
|
processor = EmbeddingProcessor()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# Получаем статистику
|
|||
|
|
stats = processor.get_processing_stats()
|
|||
|
|
logger.info(f"📊 Текущая статистика:")
|
|||
|
|
logger.info(f" Всего отелей: {stats.get('total_hotels', 0)}")
|
|||
|
|
logger.info(f" Всего страниц: {stats.get('total_pages', 0)}")
|
|||
|
|
logger.info(f" Обработано отелей: {stats.get('processed_hotels', 0)}")
|
|||
|
|
logger.info(f" Всего chunks: {stats.get('total_chunks', 0)}")
|
|||
|
|
|
|||
|
|
# Получаем отели для обработки
|
|||
|
|
hotels_to_process = processor.get_hotels_to_process()
|
|||
|
|
|
|||
|
|
if not hotels_to_process:
|
|||
|
|
logger.info("✅ Все отели уже обработаны!")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# Обрабатываем отели
|
|||
|
|
successful = 0
|
|||
|
|
failed = 0
|
|||
|
|
|
|||
|
|
for i, hotel_id in enumerate(hotels_to_process, 1):
|
|||
|
|
logger.info(f"\n🔄 Обрабатываем отель {i}/{len(hotels_to_process)}: {hotel_id}")
|
|||
|
|
|
|||
|
|
start_time = time.time()
|
|||
|
|
|
|||
|
|
if processor.process_hotel(hotel_id):
|
|||
|
|
successful += 1
|
|||
|
|
processing_time = time.time() - start_time
|
|||
|
|
logger.info(f"✅ Успешно за {processing_time:.2f} сек")
|
|||
|
|
else:
|
|||
|
|
failed += 1
|
|||
|
|
logger.error(f"❌ Ошибка обработки")
|
|||
|
|
|
|||
|
|
# Показываем прогресс каждые 10 отелей
|
|||
|
|
if i % 10 == 0:
|
|||
|
|
logger.info(f"\n📈 Прогресс: {i}/{len(hotels_to_process)} отелей")
|
|||
|
|
logger.info(f" ✅ Успешно: {successful}")
|
|||
|
|
logger.info(f" ❌ Ошибок: {failed}")
|
|||
|
|
|
|||
|
|
# Финальная статистика
|
|||
|
|
final_stats = processor.get_processing_stats()
|
|||
|
|
logger.info(f"\n🎉 ОБРАБОТКА ЗАВЕРШЕНА!")
|
|||
|
|
logger.info(f"📊 Финальная статистика:")
|
|||
|
|
logger.info(f" Обработано отелей: {final_stats.get('processed_hotels', 0)}")
|
|||
|
|
logger.info(f" Всего chunks: {final_stats.get('total_chunks', 0)}")
|
|||
|
|
logger.info(f" ✅ Успешно: {successful}")
|
|||
|
|
logger.info(f" ❌ Ошибок: {failed}")
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Критическая ошибка: {e}")
|
|||
|
|
finally:
|
|||
|
|
processor.close()
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
main()
|