diff --git a/rag/.env.example b/.env.example similarity index 67% rename from rag/.env.example rename to .env.example index 04e6a41..3dcb4c9 100644 --- a/rag/.env.example +++ b/.env.example @@ -4,3 +4,8 @@ CONF_URL= # Имя пользователя и его пароль для авторизации CONF_USERNAME= CONF_PASSWORD= + +# Порты сервисов на хосте +OLLAMA_PORT=11434 +QDRANT_PORT=6333 +OWEBUI_PORT=9999 diff --git a/.gitignore b/.gitignore index 4e43a58..24fe2f7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,12 @@ /.data/* -/rag/input_html*/* -/rag/output_md*/* -/rag/ready_rag*/* -/rag/venv +/rag/input_html/* +/rag/input_md/* +/rag/sys_prompt.txt + +.old/ +.venv/ .env *.html -*.pdf -*.sqlite* -*.bin -*.pickle !.gitkeep diff --git a/README.md b/README.md index 4c65e82..97ed0b2 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ ``` ./ ├── models/ # Директория со скриптами установки моделей ollama -├── rag/ # Директория для подготовки RAG +├── rag/ # Директория для работы с RAG ├── up # Скрипт для запуска ollama + open-webui ├── down # Скрипт для остановки ollama + open-webui ├── ollama # Скрипт для выполнения произвольных команд ollama @@ -31,6 +31,7 @@ * python, venv, pip * [docker](https://docker.com) * [ollama](https://ollama.com) +* [qdrant](https://qdrant.tech) * [open-webui](https://docs.openwebui.com) ## Как использовать diff --git a/compose.yml b/compose.yml index aee3071..dfb4ff3 100644 --- a/compose.yml +++ b/compose.yml @@ -1,21 +1,32 @@ -version: '3.8' services: ai-ollama: - image: ollama/ollama container_name: ai-ollama + image: ollama/ollama + env_file: .env volumes: - ./.data/ollama:/root/.ollama ports: - - "11434:11434" + - "${OLLAMA_PORT:-11434}:11434" + restart: "no" + + ai-qdrant: + container_name: ai-qdrant + image: qdrant/qdrant + env_file: .env + ports: + - "{QDRANT_PORT:-6333}:6333" + volumes: + - ./.data/qdrant/storage:/qdrant/storage restart: "no" ai-webui: - image: ghcr.io/open-webui/open-webui:main container_name: ai-webui + image: ghcr.io/open-webui/open-webui:main + env_file: .env volumes: - ./.data/webui:/app/backend/data ports: - - "9999:8080" + - "${OWEBUI_PORT:-9999}:8080" extra_hosts: - "host.docker.internal:host-gateway" restart: "no" diff --git a/rag/1_download_pdf.sh b/rag/1_download_pdf.sh deleted file mode 100755 index b2232e9..0000000 --- a/rag/1_download_pdf.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/bin/bash - -command -v curl >/dev/null 2>&1 || { echo >&2 "Ошибка: curl не установлен"; exit 1; } -command -v jq >/dev/null 2>&1 || { echo >&2 "Ошибка: jq не установлен"; exit 1; } - -if [ $# -lt 1 ]; then - echo >&2 "Ошибка: не указан ID страницы для загрузки" - echo "Использование: $0 " - exit 1 -fi - -[ ! -f .env ] && cp .env.example .env -source .env - -[ -z "$CONF_URL" ] && { echo >&2 "Ошибка: CONF_URL не указан в файле .env"; exit 1; } -[ -z "$CONF_USERNAME" ] && { echo >&2 "Ошибка: CONF_USERNAME не указан в файле .env"; exit 1; } -[ -z "$CONF_PASSWORD" ] && { echo >&2 "Ошибка: CONF_PASSWORD не указан в файле .env"; exit 1; } - -PAGE_ID="$1" -API_ENDPOINT="${CONF_URL}/spaces/flyingpdf/pdfpageexport.action?pageId=${PAGE_ID}" - -OUTPUT_PATH="./input_pdf" -PDF_PATH="$OUTPUT_PATH/$PAGE_ID.pdf" -[ ! -d "$OUTPUT_PATH" ] && mkdir -p "$OUTPUT_PATH" - -echo -echo "Загрузка: $API_ENDPOINT" - -result=$(curl \ - --silent \ - --location \ - --user "$CONF_USERNAME:$CONF_PASSWORD" \ - --header "Accept: application/json" \ - --output "$PDF_PATH" \ - "${API_ENDPOINT}") - -if [ ! -f "$PDF_PATH" ]; then - echo "Ошибка $result" - exit -fi - -API_ENDPOINT="${CONF_URL}/rest/api/content/${PAGE_ID}?expand=children.page" -response=$(curl \ - --silent \ - --user "$CONF_USERNAME:$CONF_PASSWORD" \ - --header "Accept: application/json" \ - "${API_ENDPOINT}" -) - -if [ $? -ne 0 ]; then - echo "$response" - exit 1 -fi - -error_message=$(echo "$response" | jq -r '.message' 2>/dev/null) -if [ -n "$error_message" ] && [ "$error_message" != "null" ]; then - echo "$response" - exit 1 -fi - -title=$(echo "$response" | jq -r .title) -PDF_TITLED_PATH="$OUTPUT_PATH/${title//\//_}.pdf" -mv "$PDF_PATH" "$PDF_TITLED_PATH" -echo "Сохранено: $PDF_TITLED_PATH" - -child_ids=$(echo "$response" | jq -r '.children.page.results[]?.id' 2>/dev/null) -for child_id in $child_ids; do - echo "Переход к дочерней странице: $child_id" - $0 "$child_id" -done diff --git a/rag/2_html_to_md.py b/rag/2_html_to_md.py deleted file mode 100644 index ae1bb30..0000000 --- a/rag/2_html_to_md.py +++ /dev/null @@ -1,325 +0,0 @@ -#!/usr/bin/env python3 -""" -RAG System for Local Ollama -Конвертирует html-файлы в markdown, очищая от лишней разметки -Скрипт сгенерирован claude-sonnet-4 -""" - -import os -import re -import json -from bs4 import BeautifulSoup -from pathlib import Path - - -def clean_confluence_html(soup): - """ - Удаляет Confluence-специфичные элементы и очищает HTML. - """ - # Удаляем Confluence макросы (structured-macro) - for macro in soup.find_all('ac:structured-macro'): - macro_name = macro.get('ac:name', '') - - # Сохраняем содержимое некоторых макросов - if macro_name == 'note': - # Преобразуем заметки в блоки внимания - rich_text = macro.find('ac:rich-text-body') - if rich_text: - note_content = rich_text.get_text(strip=True) - note_tag = soup.new_tag('div', class_='note') - note_tag.string = f"📝 **Примечание:** {note_content}" - macro.replace_with(note_tag) - else: - macro.decompose() - elif macro_name == 'toc': - # Заменяем TOC на текст - toc_tag = soup.new_tag('div') - toc_tag.string = "**Содержание** (автогенерируется)" - macro.replace_with(toc_tag) - elif macro_name == 'drawio': - # Заменяем диаграммы на заглушку - diagram_name = macro.find('ac:parameter', {'ac:name': 'diagramName'}) - if diagram_name: - diagram_text = diagram_name.get_text() - else: - diagram_text = 'Диаграмма' - diagram_tag = soup.new_tag('div') - diagram_tag.string = f"🖼️ **Диаграмма:** {diagram_text}" - macro.replace_with(diagram_tag) - else: - # Удаляем остальные макросы - macro.decompose() - - # Удаляем другие Confluence элементы - for element in soup.find_all(True): - if element.name and element.name.startswith('ac:'): - element.decompose() - - return soup - - -def convert_table_to_markdown(table): - """ - Конвертирует HTML таблицу в Markdown формат. - """ - rows = table.find_all('tr') - if not rows: - return "" - - markdown_lines = [] - - # Обработка первой строки как заголовка - first_row = rows[0] - header_cells = first_row.find_all(['th', 'td']) - - if not header_cells: - return "" - - # Заголовок таблицы - header_line = "|" - separator_line = "|" - - for cell in header_cells: - # Получаем текст и очищаем его - cell_text = cell.get_text(separator=' ', strip=True) - cell_text = re.sub(r'\s+', ' ', cell_text) # Заменяем множественные пробелы - cell_text = cell_text.replace('|', '\\|') # Экранируем pipe символы - - header_line += f" {cell_text} |" - separator_line += " --- |" - - markdown_lines.append(header_line) - markdown_lines.append(separator_line) - - # Обработка остальных строк - for row in rows[1:]: - data_cells = row.find_all(['td', 'th']) - if not data_cells: - continue - - data_line = "|" - for i, cell in enumerate(data_cells): - if i >= len(header_cells): # Не больше столбцов чем в заголовке - break - - cell_text = cell.get_text(separator=' ', strip=True) - cell_text = re.sub(r'\s+', ' ', cell_text) - cell_text = cell_text.replace('|', '\\|') - - data_line += f" {cell_text} |" - - # Дополняем недостающие столбцы - missing_cols = len(header_cells) - len(data_cells) - for _ in range(missing_cols): - data_line += " |" - - markdown_lines.append(data_line) - - return "\n".join(markdown_lines) - - -def extract_json_blocks(soup): - """ - Извлекает и форматирует JSON блоки. - """ - json_blocks = [] - - # Ищем потенциальные JSON блоки в pre, code и script тегах - for element in soup.find_all(['pre', 'code', 'script']): - text_content = element.get_text(strip=True) - - # Простая проверка на JSON - if text_content and ( - (text_content.startswith('{') and text_content.endswith('}')) or - (text_content.startswith('[') and text_content.endswith(']')) - ): - try: - # Пытаемся парсить как JSON - json_data = json.loads(text_content) - formatted_json = json.dumps(json_data, indent=2, ensure_ascii=False) - - # Заменяем элемент на форматированный JSON блок - json_tag = soup.new_tag('pre') - json_tag.string = f"```json\n{formatted_json}\n```" - element.replace_with(json_tag) - json_blocks.append(formatted_json) - - except json.JSONDecodeError: - # Если не JSON, оставляем как code block - if element.name in ['pre', 'code']: - element.string = f"```\n{text_content}\n```" - - return json_blocks - - -def html_to_markdown(html_content): - """ - Основная функция конвертации HTML в Markdown. - """ - soup = BeautifulSoup(html_content, 'html.parser') - - # Удаляем скрипты и стили - for element in soup(['script', 'style']): - element.decompose() - - # Очищаем Confluence элементы - soup = clean_confluence_html(soup) - - # Извлекаем JSON блоки - json_blocks = extract_json_blocks(soup) - - # Конвертируем таблицы - for table in soup.find_all('table'): - markdown_table = convert_table_to_markdown(table) - if markdown_table: - # Заменяем таблицу на Markdown - table_div = soup.new_tag('div', class_='markdown-table') - table_div.string = f"\n{markdown_table}\n" - table.replace_with(table_div) - - # Обработка заголовков - for level in range(1, 7): - for header in soup.find_all(f'h{level}'): - header_text = header.get_text(strip=True) - markdown_header = '#' * level + ' ' + header_text - header.string = markdown_header - - # Обработка списков - for ul in soup.find_all('ul'): - list_items = ul.find_all('li', recursive=False) - if list_items: - markdown_list = [] - for li in list_items: - item_text = li.get_text(strip=True) - markdown_list.append(f"- {item_text}") - ul.string = '\n'.join(markdown_list) - - for ol in soup.find_all('ol'): - list_items = ol.find_all('li', recursive=False) - if list_items: - markdown_list = [] - for i, li in enumerate(list_items, 1): - item_text = li.get_text(strip=True) - markdown_list.append(f"{i}. {item_text}") - ol.string = '\n'.join(markdown_list) - - # Обработка жирного и курсивного текста - for strong in soup.find_all(['strong', 'b']): - text = strong.get_text() - strong.string = f"**{text}**" - - for em in soup.find_all(['em', 'i']): - text = em.get_text() - em.string = f"*{text}*" - - # Получаем финальный текст - text = soup.get_text(separator='\n', strip=True) - - # Постобработка - lines = [] - for line in text.split('\n'): - line = line.strip() - if line: - lines.append(line) - - # Убираем лишние пустые строки - result_lines = [] - prev_empty = False - - for line in lines: - if not line: - if not prev_empty: - result_lines.append('') - prev_empty = True - else: - result_lines.append(line) - prev_empty = False - - return '\n'.join(result_lines) - - -def process_files(input_dir, output_dir): - """ - Обрабатывает все HTML-файлы в директории. - """ - input_path = Path(input_dir) - output_path = Path(output_dir) - - if not input_path.exists(): - print(f"❌ Директория {input_dir} не найдена") - return - - # Создаем выходную директорию - output_path.mkdir(parents=True, exist_ok=True) - - html_files = list(input_path.glob('*.html')) - - if not html_files: - print(f"❌ HTML файлы не найдены в {input_dir}") - return - - print(f"📁 Найдено {len(html_files)} HTML файлов") - - successful = 0 - failed = 0 - failed_files = [] - - for html_file in html_files: - print(f"🔄 Обработка: {html_file.name}") - - try: - # Читаем HTML файл - with open(html_file, 'r', encoding='utf-8') as f: - html_content = f.read() - - # Проверяем, что файл не пустой - if not html_content.strip(): - print(f"⚠️ Пропущен: {html_file.name} (пустой файл)") - continue - - # Конвертируем в Markdown - markdown_content = html_to_markdown(html_content) - - # Проверяем результат конвертации - if not markdown_content.strip(): - print(f"⚠️ Предупреждение: {html_file.name} - результат конвертации пустой") - - # Сохраняем результат - md_filename = html_file.stem + '.md' - md_filepath = output_path / md_filename - - with open(md_filepath, 'w', encoding='utf-8') as f: - f.write(markdown_content) - - print(f"✅ Сохранено: {md_filename}") - successful += 1 - - except UnicodeDecodeError as e: - print(f"❌ Ошибка кодировки в {html_file.name}: {str(e)}") - failed += 1 - failed_files.append((html_file.name, f"Ошибка кодировки: {str(e)}")) - except Exception as e: - print(f"❌ Ошибка при обработке {html_file.name}: {str(e)}") - failed += 1 - failed_files.append((html_file.name, str(e))) - - print(f"\n📊 Результат:") - print(f"✅ Успешно обработано: {successful}") - print(f"❌ Ошибок: {failed}") - - if failed_files: - print(f"\n📋 Список файлов с ошибками:") - for filename, error in failed_files: - print(f" • {filename}: {error}") - - print(f"📂 Результаты сохранены в: {output_dir}") - - -if __name__ == "__main__": - input_directory = "./input_html" - output_directory = "./output_md" - - print("🚀 Запуск конвертера HTML → Markdown") - print("=" * 50) - - process_files(input_directory, output_directory) diff --git a/rag/3_rag.py b/rag/3_rag.py deleted file mode 100644 index c93cfe0..0000000 --- a/rag/3_rag.py +++ /dev/null @@ -1,509 +0,0 @@ -#!/usr/bin/env python3 -""" -RAG System with Local Embeddings -Создает и использует RAG на основе markdown файлов с локальными эмбеддингами -""" - -import os -import json -import hashlib -from pathlib import Path -from typing import List, Dict, Any -import requests -import argparse -from datetime import datetime -import re - -try: - import numpy as np - import chromadb - from chromadb.config import Settings - from sentence_transformers import SentenceTransformer -except ImportError: - print("Устанавливаем необходимые зависимости...") - os.system("pip install chromadb numpy sentence-transformers") - import numpy as np - import chromadb - from chromadb.config import Settings - from sentence_transformers import SentenceTransformer - - -class LocalRAGSystem: - def __init__(self, - md_folder: str = "output_md", - db_path: str = "ready_rag", - ollama_url: str = "http://localhost:11434", - embed_model: str = "nomic-embed-text", - chat_model: str = "phi:2.7b"): - - self.md_folder = Path(md_folder) - self.db_path = Path(db_path) - self.ollama_url = ollama_url - self.embed_model = embed_model - self.chat_model = chat_model - - # Инициализируем модель для эмбеддингов - print(f"Загрузка модели эмбеддингов: {embed_model}...") - self.embedding_model = SentenceTransformer(embed_model) - print(f"Модель {embed_model} загружена") - - # Создаем папку для базы данных - self.db_path.mkdir(exist_ok=True) - - # Инициализируем ChromaDB (удаляем старую коллекцию при необходимости) - self.chroma_client = chromadb.PersistentClient(path=str(self.db_path)) - - # Получаем размерность текущей модели эмбеддингов - embedding_dimension = self.embedding_model.get_sentence_embedding_dimension() - - # Пытаемся получить коллекцию - try: - self.collection = self.chroma_client.get_collection( - name="md_documents" - ) - # Проверяем совпадение размерности - if self.collection.metadata.get("embedding_dimension") != str(embedding_dimension): - print("Размерность эмбеддингов изменилась, пересоздаем коллекцию...") - self.chroma_client.delete_collection(name="md_documents") - self.collection = self.chroma_client.create_collection( - name="md_documents", - metadata={ - "description": "RAG collection for markdown documents", - "embedding_dimension": str(embedding_dimension) - } - ) - except: - # Коллекция не существует, создаем новую - self.collection = self.chroma_client.create_collection( - name="md_documents", - metadata={ - "description": "RAG collection for markdown documents", - "embedding_dimension": str(embedding_dimension) - } - ) - - print(f"RAG система инициализирована:") - print(f"- Папка с MD файлами: {self.md_folder}") - print(f"- База данных: {self.db_path}") - print(f"- Ollama URL: {self.ollama_url}") - print(f"- Модель эмбеддингов: {self.embed_model}") - print(f"- Модель чата: {self.chat_model}") - - def check_ollama_connection(self) -> bool: - """Проверяем подключение к Ollama""" - try: - response = requests.get(f"{self.ollama_url}/api/tags") - return response.status_code == 200 - except: - return False - - def get_ollama_models(self) -> List[str]: - """Получаем список доступных моделей в Ollama""" - try: - response = requests.get(f"{self.ollama_url}/api/tags") - if response.status_code == 200: - models = response.json().get('models', []) - return [model['name'] for model in models] - return [] - except: - return [] - - def find_model(self, model_name: str, available_models: List[str]) -> str: - """Найти модель по имени, учитывая суффикс :latest""" - # Сначала ищем точное совпадение - if model_name in available_models: - return model_name - - # Затем ищем с суффиксом :latest - if f"{model_name}:latest" in available_models: - return f"{model_name}:latest" - - # Если модель содержит :latest, пробуем без него - if model_name.endswith(":latest"): - base_name = model_name[:-7] # убираем ":latest" - if base_name in available_models: - return base_name - - return None - - def chunk_text(self, text: str, chunk_size: int = 1500, overlap: int = 100) -> List[str]: - """Разбиваем текст на чанки с перекрытием""" - chunks = [] - start = 0 - - while start < len(text): - end = start + chunk_size - chunk = text[start:end] - - # Попытаемся разбить по предложениям - if end < len(text): - last_period = chunk.rfind('.') - last_newline = chunk.rfind('\n') - split_point = max(last_period, last_newline) - - if split_point > start + chunk_size // 2: - chunk = text[start:split_point + 1] - end = split_point + 1 - - chunks.append(chunk.strip()) - start = max(start + chunk_size - overlap, end - overlap) - - if start >= len(text): - break - - return [chunk for chunk in chunks if len(chunk.strip()) > 50] - - def extract_metadata(self, text: str, filename: str) -> Dict[str, Any]: - """Извлекаем метаданные из markdown файла""" - metadata = { - 'filename': filename, - 'length': len(text), - 'created_at': datetime.now().isoformat() - } - - # Ищем заголовки - headers = re.findall(r'^#{1,6}\s+(.+)$', text, re.MULTILINE) - if headers: - metadata['title'] = headers[0] - # Конвертируем список заголовков в строку (ChromaDB не принимает списки) - metadata['headers_text'] = '; '.join(headers[:5]) - metadata['headers_count'] = len(headers) - - # Ищем специальные секции - if '# Краткое описание' in text: - desc_match = re.search(r'# Краткое описание\n(.*?)(?=\n#|\n$)', text, re.DOTALL) - if desc_match: - metadata['description'] = desc_match.group(1).strip()[:2000] - - # Ищем требования - if '# Требования' in text: - metadata['has_requirements'] = True - - # Ищем нормативные документы - if '# Нормативная документация' in text: - metadata['has_regulations'] = True - - return metadata - - def get_embedding(self, text: str) -> List[float]: - """Генерируем эмбеддинг локально с помощью SentenceTransformer""" - try: - # Генерируем эмбеддинг - embedding = self.embedding_model.encode(text, show_progress_bar=False) - return embedding.tolist() - except Exception as e: - print(f"Ошибка при генерации эмбеддинга: {e}") - return None - - def process_markdown_files(self) -> int: - """Обрабатываем все markdown файлы и добавляем их в векторную базу""" - if not self.md_folder.exists(): - print(f"Папка {self.md_folder} не найдена!") - return 0 - - md_files = list(self.md_folder.glob("*.md")) - if not md_files: - print(f"Markdown файлы не найдены в {self.md_folder}") - return 0 - - print(f"Найдено {len(md_files)} markdown файлов") - - # Проверяем подключение к Ollama (только для чат-модели) - if not self.check_ollama_connection(): - print(f"Не удается подключиться к Ollama по адресу {self.ollama_url}") - print("Убедитесь, что Ollama запущена и доступна для генерации ответов") - # Продолжаем работу, так как эмбеддинги локальные - print("Эмбеддинги будут генерироваться локально") - - total_files = len(md_files) - processed_count = 0 - total_chunks = 0 - - for idx, md_file in enumerate(md_files, 1): - print(f"\n[{idx}/{total_files}] Обрабатываем файл: {md_file.name}") - - try: - with open(md_file, 'r', encoding='utf-8') as f: - content = f.read() - - # Создаем чанки - chunks = self.chunk_text(content) - print(f" Создано чанков: {len(chunks)}") - - # Извлекаем метаданные - base_metadata = self.extract_metadata(content, md_file.name) - - # Обрабатываем каждый чанк - for i, chunk in enumerate(chunks): - # Создаем уникальный ID для чанка - chunk_id = hashlib.md5(f"{md_file.name}_{i}_{chunk[:100]}".encode()).hexdigest() - - # Получаем эмбеддинг - embedding = self.get_embedding(chunk) - if embedding is None: - print(f" Пропускаем чанк {i} - не удалось получить эмбеддинг") - continue - - # Подготавливаем метаданные для чанка - chunk_metadata = {} - # Копируем только допустимые типы метаданных - for key, value in base_metadata.items(): - if isinstance(value, (str, int, float, bool, type(None))): - chunk_metadata[key] = value - elif isinstance(value, list): - # Конвертируем списки в строки - chunk_metadata[key] = '; '.join(map(str, value)) - else: - # Конвертируем другие типы в строки - chunk_metadata[key] = str(value) - - chunk_metadata.update({ - 'chunk_id': i, - 'chunk_size': len(chunk), - 'source_file': str(md_file) - }) - - # Добавляем в коллекцию - self.collection.add( - embeddings=[embedding], - documents=[chunk], - metadatas=[chunk_metadata], - ids=[chunk_id] - ) - - total_chunks += 1 - - processed_count += 1 - print(f" Успешно обработан") - - except Exception as e: - print(f" Ошибка при обработке: {e}") - continue - - print(f"\nОбработка завершена:") - print(f"- Обработано файлов: {processed_count}") - print(f"- Создано чанков: {total_chunks}") - - return processed_count - - def search(self, query: str, n_results: int = 10) -> List[Dict]: - """Поиск релевантных документов""" - if self.collection.count() == 0: - return [] - - # Получаем эмбеддинг для запроса - query_embedding = self.get_embedding(query) - if query_embedding is None: - print("Не удалось получить эмбеддинг для запроса") - return [] - - # Ищем похожие документы - results = self.collection.query( - query_embeddings=[query_embedding], - n_results=n_results - ) - - # Форматируем результаты - formatted_results = [] - for i in range(len(results['documents'][0])): - formatted_results.append({ - 'document': results['documents'][0][i], - 'metadata': results['metadatas'][0][i], - 'distance': results['distances'][0][i] if 'distances' in results else None - }) - - return formatted_results - - def generate_response(self, query: str, context_docs: List[Dict]) -> str: - """Генерируем ответ используя контекст и Ollama""" - # Формируем контекст из найденных документов - context = "" - for i, doc in enumerate(context_docs, 1): - context += f"\n--- Документ {i} (файл: {doc['metadata'].get('filename', 'unknown')}) ---\n" - context += doc['document'][:2000] + ("..." if len(doc['document']) > 2000 else "") - context += "\n" - - print(f"\nКонтекст: {context}") - - # Формируем промпт - prompt = f"""На основе предоставленного контекста ответь на вопрос на русском языке. Если ответа нет в контексте, скажи об этом. - -Контекст: -{context} - -Вопрос: {query} - -Ответ:""" - - try: - response = requests.post( - f"{self.ollama_url}/api/generate", - json={ - "model": self.chat_model, - "prompt": prompt, - "stream": False - }, - timeout=600 - ) - - if response.status_code == 200: - return response.json()["response"] - else: - return f"Ошибка генерации ответа: {response.status_code}" - - except Exception as e: - return f"Ошибка при обращении к Ollama: {e}" - - def query(self, question: str, n_results: int = 5) -> Dict: - """Полный цикл RAG: поиск + генерация ответа""" - print(f"\nВопрос: {question}") - - # Проверяем доступность чат-модели - available_models = self.get_ollama_models() - chat_model_name = self.find_model(self.chat_model, available_models) - if not chat_model_name: - return { - "question": question, - "answer": f"Модель чата {self.chat_model} не найдена в Ollama", - "sources": [] - } - - # Обновляем имя чат-модели - self.chat_model = chat_model_name - - print("Ищем релевантные документы...") - - # Поиск документов - search_results = self.search(question, n_results) - - if not search_results: - return { - "question": question, - "answer": "Не найдено релевантных документов для ответа на ваш вопрос.", - "sources": [] - } - - print(f"Найдено {len(search_results)} релевантных документов") - - # Генерация ответа - print("Генерируем ответ...") - answer = self.generate_response(question, search_results) - - # Формируем источники - sources = [] - for doc in search_results: - sources.append({ - "filename": doc['metadata'].get('filename', 'unknown'), - "title": doc['metadata'].get('title', ''), - "distance": doc.get('distance', 0) - }) - - return { - "question": question, - "answer": answer, - "sources": sources, - "context_docs": len(search_results) - } - - def get_stats(self) -> Dict: - """Получаем статистику RAG системы""" - return { - "total_documents": self.collection.count(), - "embedding_model": self.embed_model, - "chat_model": self.chat_model, - "database_path": str(self.db_path), - "source_folder": str(self.md_folder) - } - - -def main(): - parser = argparse.ArgumentParser(description="RAG System for Local Ollama") - parser.add_argument("--action", choices=["build", "query", "interactive", "stats"], - default="interactive", help="Действие для выполнения") - parser.add_argument("--question", type=str, help="Вопрос для поиска") - parser.add_argument("--md-folder", default="output_md", help="Папка с markdown файлами") - parser.add_argument("--embed-model", default="all-MiniLM-L6-v2", help="Модель для эмбеддингов (SentenceTransformer)") - parser.add_argument("--chat-model", default="gemma3n:e2b", help="Модель для чата") - parser.add_argument("--results", type=int, default=6, help="Количество результатов поиска") - - args = parser.parse_args() - - # Создаем RAG систему - rag = LocalRAGSystem( - md_folder=args.md_folder, - embed_model=args.embed_model, - chat_model=args.chat_model - ) - - if args.action == "build": - print("Строим RAG базу данных...") - count = rag.process_markdown_files() - print(f"Обработано {count} файлов") - - elif args.action == "query": - if not args.question: - print("Укажите вопрос с помощью --question") - return - - result = rag.query(args.question, args.results) - print(f"\nВопрос: {result['question']}") - print(f"\nОтвет:\n{result['answer']}") - print(f"\nИсточники:") - for source in result['sources']: - print(f"- {source['filename']}: {source['title']}") - - elif args.action == "stats": - stats = rag.get_stats() - print("Статистика RAG системы:") - for key, value in stats.items(): - print(f"- {key}: {value}") - - elif args.action == "interactive": - print("\n=== Интерактивный режим RAG системы ===") - print("Введите 'exit' для выхода, 'stats' для статистики") - - # Проверяем, есть ли данные в базе - if rag.collection.count() == 0: - print("\nБаза данных пуста. Строим RAG...") - count = rag.process_markdown_files() - if count == 0: - print("Не удалось построить базу данных. Завершение работы.") - return - - stats = rag.get_stats() - print(f"\nДоступно документов: {stats['total_documents']}") - print(f"Модель эмбеддингов: {stats['embedding_model']}") - print(f"Модель чата: {stats['chat_model']}\n") - - while True: - try: - question = input("\nВаш вопрос: ").strip() - - if question.lower() == 'exit': - break - elif question.lower() == 'stats': - stats = rag.get_stats() - for key, value in stats.items(): - print(f"- {key}: {value}") - continue - elif not question: - continue - - result = rag.query(question, args.results) - print(f"\nОтвет:\n{result['answer']}") - - print(f"\nИсточники ({len(result['sources'])}):") - for i, source in enumerate(result['sources'], 1): - print(f"{i}. {source['filename']}") - if source['title']: - print(f" Заголовок: {source['title']}") - - except KeyboardInterrupt: - print("\nЗавершение работы...") - break - except Exception as e: - print(f"Ошибка: {e}") - - -if __name__ == "__main__": - main() diff --git a/rag/README.md b/rag/README.md index 42b7378..469a014 100644 --- a/rag/README.md +++ b/rag/README.md @@ -1,11 +1,27 @@ -# RAG для Confluence-документации +# Базовый RAG для локального запуска + +Этот проект представляет собой систему RAG, которая позволяет преобразовывать документацию из Confluence или других источников в формат, пригодный для работы с локальной Ollama, и задавать вопросы по содержимому документов. + +## Быстрый старт + +```bash +cd ..; ./up; cd - +python3 -m venv .venv +source ./venv/bin/activate +pip install beautifulsoup4 markdownify sentence-transformers qdrant-client langchain transformers hashlib +pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu +./download.sh 123456789 +python3 convert.py +python3 vectorize.py +python3 rag.py --interactive +``` ## Что такое RAG? RAG (Retrieval-Augmented Generation) — это архитектура, которая расширяет возможности генеративных языковых моделей (LLM) за счет интеграции внешних источников знаний. -Вместо того чтобы полагаться исключительно на информацию, полученную во время обучения, RAG сначала извлекает релевантные фрагменты из внешней базы знаний, а затем использует их для генерации более точных и информативных ответов. +Вместо того, чтобы полагаться исключительно на информацию, полученную во время обучения, RAG сначала извлекает релевантные фрагменты из внешней базы знаний, а затем использует их для генерации более точных и информативных ответов. -Основные компоненты RAG: +Основные шаги подготовки RAG: - **Индексация**: Документы преобразуются в векторные представления (эмбеддинги) и сохраняются в векторной базе данных - **Поиск**: При поступлении запроса система ищет наиболее релевантные фрагменты из индексированной базы - **Генерация**: Найденные фрагменты используются как контекст для генерации ответа с помощью языковой модели @@ -16,183 +32,189 @@ RAG (Retrieval-Augmented Generation) — это архитектура, кото - Дает возможность проверить источник информации в сгенерированном ответе - Может работать с проприетарными или конфиденциальными данными без дообучения модели -Этот проект представляет собой систему RAG, которая позволяет преобразовывать документацию из Confluence в формат, пригодный для работы с локальной Ollama, и задавать вопросы по содержимому документации. - ## Структура проекта ``` rag/ -├── .env.example # Пример файла конфигурации для подключения к Confluence -├── 1_download_page.sh # Скрипт для загрузки страниц из Confluence -├── 2_html_to_md.py # Скрипт конвертации HTML в Markdown -├── 3_rag.py # Основной скрипт RAG системы -├── quickstart.sh # Скрипт быстрого запуска всего процесса -├── input_html/ # Входные HTML файлы (загруженные из Confluence) -├── output_md/ # Конвертированные Markdown файлы -├── ready_rag/ # Готовая векторная база данных для RAG -└── README.md # Этот файл +├── input_html/ # Входные файлы HTML, загруженные из Confluence +├── input_md/ # Входные (конвертированные) файлы Markdown +├── download.sh # Скрипт для загрузки страниц из Confluence +├── convert.py # Скрипт конвертации HTML в Markdown +├── vectorize.py # Скрипт векторизации Markdown +├── rag.py # Основной скрипт RAG системы +├── clear.sh # Скрипт очистки html/md файлов +└── README.md # Этот файл ``` ## Стек * bash * python, venv, pip -* [docker](https://docker.com) * [ollama](https://ollama.com) +* [qdrant](https://qdrant.tech) * [open-webui](https://docs.openwebui.com) ## Предварительные требования -1. Установить ПО: `curl`, `jq`, `python3`, `pip` -2. Установить зависимости python: +1. Запустить среду из корня репозитория: [../README.md](../README.md) +2. Установить ПО: `curl`, `jq`, `python3`, `pip` +3. Установить зависимости python: ```bash -source venv/bin/activate -pip install chromadb numpy requests beautifulsoup4 +python3 -m venv .venv +source ./venv/bin/activate +pip install beautifulsoup4 markdownify sentence-transformers qdrant-client langchain transformers +pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu ``` -3. Запустить сервер Ollama: `../up` -4. Установить модели Ollama (рекомендуется): - - `nomic-embed-text:latest` — для эмбеддингов - (установить через `../models/nomic-embed-text/latest`) - - `phi4-mini:3.8b` или другая подходящая модель — для генерации ответов - (установить через `../models/phi4/mini:3.8b`) +4. Установить в ollama генеративную модель -- читай ниже. -## Настройка +## Подготовка данных -1. Создайте файл конфигурации на основе примера: `cp .env.example .env` -2. Отредактируйте файл `.env`, указав свои данные +### Выгрузка из Confluence -Файл используется только для подключения к Confluence. +Открыть страницу (раздел) Confluence, который необходимо получить. +Например, `https://conf.company.ltd/pages/viewpage.action?pageId=123456789`. -## Использование - -### Способ 1: Быстрый запуск (рекомендуется) - -Запустите скрипт быстрого старта, указав ID страницы Confluence: +Скопировать значение `pageId` и подставить в команду `./download.sh `. +Например, ```bash -cd rag -./quickstart.sh 123456789 +./download.sh 123456789 ``` -где `123456789` - ID страницы Confluence, которую вы хотите обработать. +В результате сама страница и все её дочерние будут сохранены в директорию `./input_html/`. +Файлы будут названы по заголовкам страниц. -Скрипт автоматически: -- Создаст виртуальное окружение Python -- Установит необходимые зависимости -- Загрузит страницу и все дочерние страницы -- Конвертирует HTML в Markdown -- Построит векторную базу данных -- Запустит интерактивный режим чата +> [!IMPORTANT] +> В начале каждого файла будет сохранена исходная ссылка в формате `@@https://conf.company.ltd/pages/viewpage.action?pageId=123456789@@`. +> Это сделано для того, чтобы в диалоге с моделью источники информации отображались в виде ссылок, а не названий файлов. -### Способ 2: Пошаговая настройка +> [!IMPORTANT] +> Confluence не позволяет получить готовые макросы в HTML-виде. +> В файлах будет находиться HTML-текст с готовыми к загрузками макросами, но не загруженными. +> Хотя в них часто есть очень полезная информация, но её приходится выбрасывать просто потому, что загрузка макросов происходит при загрузке страницы браузером. +> Поэтому, например, содержания, списки дочерних страниц, встраиваемые диаграммы и пр. плюшечки будут вырезаны. +> +> Да, можно запросить страницы простым curl/wget, но (1) см. предыдущее замечание и (2) даже после очистки HTML-тегов в тексте останется очень много мусора (меню, футер, навигация...) -#### 1. Загрузка страниц из Confluence +### Конвертация страниц в Markdown + +Этот формат наиболее хорошо подходит для цитирования, потому что не содержит лишних символов, которые будут мешать хорошей токенизации и векторизации. + +Для конвертации следует выполнить команду: ```bash -./1_download_page.sh 123456789 +python3 convert.py ``` -Этот скрипт: -- Загружает указанную страницу и все её дочерние страницы -- Сохраняет HTML-файлы в директорию `input_html/` -- Рекурсивно обрабатывает всю иерархию страниц +В результате все html-файлы будут сохранены в директорию `./input_md/`. +Файлы будут названы по заголовкам страниц, внутри также сохранится ссылка на исходную страницу `@@...@@`. -#### 2. Конвертация HTML в Markdown +Для получения справки по скрипту выполни команду: + +``` +python3 convert.py --help +``` + +### Векторизация (индексирование) + +Файлы `./input_md/*.md` должны быть проиндексированы. + +Для того, чтобы проиндексировать Markdown-документы, выполнить команду: ```bash -python3 2_html_to_md.py +python3 vectorize.py ``` -Этот скрипт: -- Обрабатывает все HTML-файлы в директории `input_html/` -- Конвертирует их в Markdown с сохранением структуры -- Сохраняет результаты в директории `output_md/` -- Очищает от Confluence-специфичной разметки +Весь текст делится на отрезки (чанки) с некоторым перекрытием. +Это делается для оптимизации количества информации, которое будет делиться на токены. +Перекрытие обеспечивает смысловую связь между чанками. -#### 3. Построение RAG базы данных +Каждый чанк разбивается на токены, которые в виде векторов сохраняются в векторную СУБД Qdrant. +При работе RAG, близость векторов токенов обеспечивает наибольшее смысловое совпадение разных слов => чанков => предложений => абзацев => документов. +Как следствие: +- наибольшее смысловое совпадение с вопросом пользователя; +- молниеносный поиск по индексу чанков (частям документов); +- корректное насыщение контекста для генеративной модели. -```bash -python3 3_rag.py --action build +Впоследствии embedding-модель будет встраивать эти данные в диалог с генеративной моделью. +Каждый запрос сначала будет обрабатывать именно она, находя подходящие по векторам документы, и подставлять их в контекст генеративной модели. +Последняя будет всего лишь генерировать ответ, опираясь на предоставленные из документов данные, ссылаясь на них в ответе. + +Для получения справки по скрипту выполни команду: + +``` +python3 vectorize.py --help ``` -Этот скрипт: -- Создает векторную базу данных на основе Markdown-файлов -- Генерирует эмбеддинги с помощью Ollama -- Сохраняет базу данных в директории `ready_rag/` +## Запуск -#### 4. Взаимодействие с RAG системой +Для работы с RAG в интерактивном режиме (симуляция диалога) следует выполнить команду: -```bash -python3 3_rag.py --action interactive +``` +python3 rag.py --interactive ``` -В интерактивном режиме: -- Введите свой вопрос и нажмите Enter -- Система найдет релевантные документы и сгенерирует ответ -- Введите `exit` для выхода -- Введите `stats` для просмотра статистики +> [!IMPORTANT] +> Модель не запоминает ничего, поскольку сам диалог не попадает в контекст! +> В целом, это похоже на гуглёж. -Также можно выполнить одиночный запрос: +Для разового запуска RAG следует выполнить команду: -```bash -python3 3_rag.py --action query --question "Ваш вопрос здесь" +``` +python3 rag.py --query "твой запрос здесь" ``` -## Конфигурация +Для получения справки по скрипту выполни команду: -Вы можете настроить параметры RAG системы через аргументы командной строки: - -```bash -python3 3_rag.py \ - --md-folder output_md \ - --embed-model nomic-embed-text \ - --chat-model phi4-mini:3.8b \ - --results 5 +``` +python3 rag.py --help ``` -Доступные параметры: -- `--md-folder`: Папка с Markdown файлами (по умолчанию: `output_md`) -- `--embed-model`: Модель для генерации эмбеддингов (по умолчанию: `nomic-embed-text`) -- `--chat-model`: Модель для генерации ответов (по умолчанию: `phi4-mini:3.8b`) -- `--results`: Количество возвращаемых результатов (по умолчанию: `10`) -- -## Пример использования +### Кастомный системный промпт -1. Загрузите страницу документации: - ```bash - ./1_download_page.sh 123456789 - ``` +Если хочется уточнить роль генеративной модели, можно создать файл `sys_prompt.txt` и прописать туда всё необходимое, учитывая следующие правила: -2. Конвертируйте в Markdown: - ```bash - python3 2_html_to_md.py - ``` +1. Шаблон `{{context}}` будет заменён на цитаты документов, найденные в qdrant +2. Шаблон `{{query}}` будет заменён на запрос пользователя +3. Если этих двух шаблонов не будет в промпте, результаты будут непредсказуемыми +4. Каждая цитата в списке цитат формируется в формате: + ``` + --- Source X --- + Lorem ipsum dolor sit amet + <пустая строка> + ``` +5. Если в этой директории нет файла `sys_prompt.txt`, то будет применён промпт по умолчанию (см. функцию `generate_answer()`). -3. Постройте RAG базу: - ```bash - python3 3_rag.py --action build - ``` +Посмотреть полный промпт можно указав аргумент `--show_prompt` при вызове `rag.py`. -4. Задайте вопрос: - ```bash - python3 3_rag.py --action query --question "Как настроить систему мониторинга?" - ``` +### Неплохие лёгкие модели -## Особенности обработки +Для эмбеддинга: -Система автоматически обрабатывает следующие элементы Confluence: +- `sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2` (по умолчанию, хорошо адаптирована под русский язык) +- `nomad-embed-text` (популярная) +- ... -- **Заметки (Note макросы)**: Конвертируются в формат `📝 **Примечание:** Текст заметки` -- **Таблицы**: Преобразуются в Markdown-таблицы с сохранением структуры -- **JSON блоки**: Форматируются и отображаются как кодовые блоки -- **Диаграммы (DrawIO)**: Заменяются на заглушки -- **Содержание (TOC)**: Заменяется на заглушки +Для генерации ответов: -## Лицензия +- `qwen2.5:3b` (по умолчанию) +- `gemma3n:e2b` +- `phi4-mini:3.8b` +- `qwen2.5:1.5b` +- ... -Этот проект распространяется под лицензией MIT. -Подробнее см. в файле LICENSE. +## Дисклеймер -Скрипты на языке python сгенерированы моделью claude-sonnet-4. +Проект родился на энтузиазме из личного любопытства. + +**Цель:** изучить современные технологии. + +**Задачи:** +1. облегчить поиск информации о проекте среди почти 2000 тысяч документов в корпоративной Confluence, относящихся к нему; +2. обеспечить минимум телодвижений для развёртывания RAG с нуля внутри команды. + +Здесь не было задачи сделать всё по красоте. + +Частично (в качестве агентов) в проекте участвовали семейства qwen3, clause-sonnet-4 и семейство chatgpt-4. diff --git a/rag/TODO.md b/rag/TODO.md new file mode 100644 index 0000000..7603f05 --- /dev/null +++ b/rag/TODO.md @@ -0,0 +1,9 @@ +# Бэклог + +* [ ] Описать подготовку знаний в Open WebUI +* [ ] Обработка pdf, json, ... +* [ ] Ранжировние результатов +* [ ] Режим диалога (запоминание запросов и ответов) +* [ ] API +* [ ] Telegram-бот +* [ ] ... diff --git a/rag/clear.sh b/rag/clear.sh index 4618517..09cc91d 100755 --- a/rag/clear.sh +++ b/rag/clear.sh @@ -1,7 +1,4 @@ #!/bin/bash rm -rf ./input_html/*.html -rm -rf ./input_pdf/*.pdf -rm -rf ./output_md/*.md -rm -rf ./ready_rag/* -touch ./ready_rag/.gitkeep +rm -rf ./input_md/*.md diff --git a/rag/convert.py b/rag/convert.py new file mode 100644 index 0000000..99d4e3d --- /dev/null +++ b/rag/convert.py @@ -0,0 +1,25 @@ +import os +import argparse +from bs4 import BeautifulSoup +import markdownify + +def convert_html_to_md(input_dir, output_dir): + os.makedirs(output_dir, exist_ok=True) + for filename in os.listdir(input_dir): + if filename.endswith(".html"): + input_path = os.path.join(input_dir, filename) + output_filename = os.path.splitext(filename)[0] + ".md" + output_path = os.path.join(output_dir, output_filename) + with open(input_path, "r", encoding="utf-8") as f: + html_content = f.read() + md_content = markdownify.markdownify(html_content, heading_style="ATX") + with open(output_path, "w", encoding="utf-8") as f: + f.write(md_content) + print(f"Converted {input_path} to {output_path}") + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Конвертер HTML-файлов в Markdown") + parser.add_argument("--input-dir", type=str, default="input_html", help="Директория с HTML-файлами для конвертации") + parser.add_argument("--output-dir", type=str, default="input_md", help="Директория для сохранения Markdown-файлов") + args = parser.parse_args() + convert_html_to_md(args.input_dir, args.output_dir) diff --git a/rag/1_download_html.sh b/rag/download.sh similarity index 86% rename from rag/1_download_html.sh rename to rag/download.sh index 09a7e4f..e2b1572 100755 --- a/rag/1_download_html.sh +++ b/rag/download.sh @@ -19,7 +19,7 @@ source .env PAGE_ID="$1" API_ENDPOINT="${CONF_URL}/rest/api/content/${PAGE_ID}?expand=body.view,children.page" -OUTPUT_PATH="./input_pdf" +OUTPUT_PATH="./input_html" [ ! -d "$OUTPUT_PATH" ] && mkdir -p "$OUTPUT_PATH" echo @@ -37,9 +37,6 @@ if [ -n "$error_message" ] && [ "$error_message" != "null" ]; then exit 1 fi -output_path="./input_html" -[ ! -d "$output_path" ] && mkdir -p "$output_path" - title=$(echo "$response" | jq -r .title) content=$(echo "$response" | jq -r .body.view.value) @@ -52,7 +49,8 @@ path="$output_path/${title//\//_}.html" content=${content//href=\"\//href=\"$CONF_URL} content=${content//src=\"\//src=\"$CONF_URL} -echo "Страница: $CONF_URL/pages/viewpage.action?pageId=$PAGE_ID

$title

$content" > "$path" +url="$CONF_URL/pages/viewpage.action?pageId=$PAGE_ID" +echo -e "@@$url@@\n
Исходная страница: $url

$title

$content" > "$path" echo "Сохранено: $output_path/$title.html" child_ids=$(echo "$response" | jq -r '.children.page.results[]?.id' 2>/dev/null) diff --git a/rag/input_pdf/.gitkeep b/rag/input_md/.gitkeep similarity index 100% rename from rag/input_pdf/.gitkeep rename to rag/input_md/.gitkeep diff --git a/rag/output_md/.gitkeep b/rag/output_md/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/rag/quickstart.sh b/rag/quickstart.sh deleted file mode 100755 index c5e92f8..0000000 --- a/rag/quickstart.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash - -is_installed() { - command -v $1 >/dev/null 2>&1 || { echo >&2 "Ошибка: $1 не установлен"; exit 1; } -} - -is_installed curl -is_installed jq -is_installed python3 -is_installed pip - -echo "Поиск зависимостей..." - -python3 -m venv venv -source venv/bin/activate -[ "$(pip install --dry-run chromadb 2>&1 | grep -c 'Would install')" -gt 0 ] && pip install chromadb -[ "$(pip install --dry-run numpy 2>&1 | grep -c 'Would install')" -gt 0 ] && pip install numpy -[ "$(pip install --dry-run requests 2>&1 | grep -c 'Would install')" -gt 0 ] && pip install requests -[ "$(pip install --dry-run beautifulsoup4 2>&1 | grep -c 'Would install')" -gt 0 ] && pip install beautifulsoup4 - -echo "Начало работы" -cd ..; ./up; cd - -./1_download_page.sh "$@" || exit 1 -python3 ./2_html_to_md.py -python3 ./3_rag.py --action build -python3 ./3_rag.py --action interactive diff --git a/rag/rag.py b/rag/rag.py new file mode 100644 index 0000000..4888b5c --- /dev/null +++ b/rag/rag.py @@ -0,0 +1,190 @@ +import argparse +import os +import hashlib +import requests +from sentence_transformers import SentenceTransformer + +class LocalRAGSystem: + def __init__(self, + md_folder: str = "input_md", + ollama_url: str = "http://localhost:11434", + qdrant_host: str = "localhost", + qdrant_port: int = 6333, + embed_model: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2", + chat_model: str = "qwen2.5:3b"): + self.md_folder = md_folder + self.ollama_url = ollama_url + self.qdrant_host = qdrant_host + self.qdrant_port = qdrant_port + self.embed_model = embed_model + self.chat_model = chat_model + self.emb_model = SentenceTransformer(embed_model) + + def get_embedding(self, text: str): + return self.emb_model.encode(text, show_progress_bar=False).tolist() + + def search_qdrant(self, query: str, top_k: int = 6): + query_vec = self.get_embedding(query) + url = f"http://{self.qdrant_host}:{self.qdrant_port}/collections/rag_collection/points/search" + payload = { + "vector": query_vec, + "top": top_k, + "with_payload": True + } + resp = requests.post(url, json=payload) + if resp.status_code != 200: + raise RuntimeError(f"> Ошибка qdrant: {resp.status_code} {resp.text}") + results = resp.json().get("result", []) + return results + + def generate_answer(self, query: str, context_docs: list): + query = query.strip() + context = f"" + sources = f"\nИсточники:\n" + for idx, doc in enumerate(context_docs, start=1): + text = doc['payload'].get("text", "").strip() + filename = doc['payload'].get("filename", None) + url = doc['payload'].get("url", None) + if filename: + title = filename + else: + snippet = text[:40].replace("\n", " ").strip() + if len(text) > 40: + snippet += "..." + title = snippet if snippet else "Empty text" + if url is None: + url = "" + context = f"{context}\n--- Source [{idx}] ---\n{text}\n" + sources = f"{sources}\n{idx}. {title}\n {url}" + + if os.path.exists('sys_prompt.txt'): + with open('sys_prompt.txt', 'r') as fp: + prompt = fp.read().replace("{{context}}", context).replace("{{query}}", query) + else: + prompt = f""" + Please provide an answer based solely on the provided sources. + It is prohibited to generate an answer based on your pretrained data. + If uncertain, ask the user for clarification. + Respond in the same language as the user's query. + If there are no sources in context, clearly state that. + If the context is unreadable or of poor quality, inform the user and provide the best possible answer. + When referencing information from a source, cite the appropriate source(s) using their corresponding numbers. + Every answer should include at least one source citation. + Only cite a source when you are explicitly referencing it. + + If none of the sources are helpful, you should indicate that. + For example: + + --- Source 1 --- + The sky is red in the evening and blue in the morning. + + --- Source 2 --- + Water is wet when the sky is red. + + Query: When is water wet? + Answer: Water will be wet when the sky is red [2], which occurs in the evening [1]. + + Now it's your turn. Below are several numbered sources of information: + {context} + + User query: {query} + Your answer: + """ + + url = f"{self.ollama_url}/api/generate" + body = { + "model": self.chat_model, + "prompt": prompt, + "stream": False + } + resp = requests.post(url, json=body, timeout=600) + if resp.status_code != 200: + return f"Ошибка генерации ответа: {resp.status_code} {resp.text}" + return resp.json().get("response", "").strip() + f"\n{sources}" + + +def main(): + import sys + import argparse + + parser = argparse.ArgumentParser(description="RAG-система с использованием Ollama и Qdrant") + parser.add_argument("--query", type=str, help="Запрос к RAG") + parser.add_argument("--interactive", default=False, action=argparse.BooleanOptionalAction, help="Перейти в интерактивный режим диалога") + parser.add_argument("--show-prompt", default=False, action=argparse.BooleanOptionalAction, help="Показать полный промпт перед обработкой запроса") + parser.add_argument("--qdrant-host", default="localhost", help="Qdrant host") + parser.add_argument("--qdrant-port", type=int, default=6333, help="Qdrant port") + parser.add_argument("--ollama-url", default="http://localhost:11434", help="Ollama API URL") + parser.add_argument("--emb-model", default="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2", help="Модель эмбеддинга") + parser.add_argument("--chat-model", default="qwen2.5:3b", help="Модель генерации Ollama") + parser.add_argument("--topk", type=int, default=6, help="Количество документов для поиска") + args = parser.parse_args() + + if not args.query and not args.interactive: + print("Ошибка: укажите запрос (--query) и/или используйте интерактивный режим (--interactive)") + sys.exit(1) + + print(f"Адрес ollama: {args.ollama_url}") + print(f"Адрес qdrant: {args.qdrant_host}:{args.qdrant_port}") + print(f"Модель эмбеддинга: {args.emb_model}") + print(f"Модель чата: {args.chat_model}") + print(f"Документов для поиска: {args.topk}") + if os.path.exists('sys_prompt.txt'): + print("Будет загружен системный промпт из sys_prompt.txt!") + + if args.interactive: + print("\nИНТЕРАКТИВНЫЙ РЕЖИМ") + print("Можете вводить запрос (или 'exit' для выхода)\n") + question = input(">>> ").strip() + else: + question = args.query.strip() + + print("\nПервая инициализация моделей...") + rag = LocalRAGSystem( + ollama_url=args.ollama_url, + qdrant_host=args.qdrant_host, + qdrant_port=args.qdrant_port, + embed_model=args.emb_model, + chat_model=args.chat_model + ) + + print(f"Модели загружены. Если ответ плохой, переформулируйте запрос, укажите --chat-model или улучшите исходные данные RAG") + + while True: + try: + if not question or question == "": + question = input(">>> ").strip() + + if not question or question == "": + continue + + if question.lower() == "exit": + print("\n*** Завершение работы") + break + + print("\nПоиск релевантных документов...") + results = rag.search_qdrant(question, top_k=args.topk) + if not results: + print("Релевантные документы не найдены.") + if args.interactive: + continue + else: + break + + print(f"Найдено {len(results)} релевантных документов") + + if args.show_prompt: + print("\nПолный системный промпт:\n") + print(rag.prompt) + + print("Генерация ответа...") + answer = rag.generate_answer(question, results) + print(f"\n<<< {answer}\n---------------------------------------------------\n") + question = None + except KeyboardInterrupt: + print("\n*** Завершение работы") + break + except Exception as e: + print(f"Ошибка: {e}") + +if __name__ == "__main__": + main() diff --git a/rag/ready_rag/.gitkeep b/rag/ready_rag/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/rag/vectorize.py b/rag/vectorize.py new file mode 100644 index 0000000..2996539 --- /dev/null +++ b/rag/vectorize.py @@ -0,0 +1,106 @@ +import os +import argparse +from sentence_transformers import SentenceTransformer +from qdrant_client import QdrantClient +from qdrant_client.http import models +from langchain.text_splitter import RecursiveCharacterTextSplitter + + +def load_markdown_files(input_dir): + documents = [] + for filename in os.listdir(input_dir): + if filename.endswith(".md"): + path = os.path.join(input_dir, filename) + with open(path, "r", encoding="utf-8") as f: + content = f.read() + lines = content.splitlines() + url = None + if lines: + first_line = lines[0].strip() + if first_line.startswith("@@") and first_line.endswith("@@") and len(first_line) > 4: + url = first_line[2:-2].strip() + content = "\n".join(lines[1:]) # Remove the first line from content + documents.append({"id": filename, "text": content, "url": url}) + return documents + +def chunk_text(texts, chunk_size, chunk_overlap): + splitter = RecursiveCharacterTextSplitter( + chunk_size=chunk_size, + chunk_overlap=chunk_overlap, + length_function=len, + separators=["\n\n", "\n", " ", ""] + ) + chunks = [] + for doc in texts: + doc_chunks = splitter.split_text(doc["text"]) + for i, chunk in enumerate(doc_chunks): + chunk_id = f"{doc['id']}_chunk{i}" + chunk_dict = {"id": chunk_id, "text": chunk} + if "url" in doc and doc["url"] is not None: + chunk_dict["url"] = doc["url"] + chunks.append(chunk_dict) + return chunks + +def embed_and_upload(chunks, embedding_model_name, qdrant_host="localhost", qdrant_port=6333): + import hashlib + + print(f"Инициализация модели {args.embedding_model}") + embedder = SentenceTransformer(embedding_model_name) + + print(f"Подключение к qdrant ({qdrant_host}:{qdrant_port})") + client = QdrantClient(host=qdrant_host, port=qdrant_port) + collection_name = "rag_collection" + + if client.collection_exists(collection_name): + client.delete_collection(collection_name) + + client.create_collection( + collection_name=collection_name, + vectors_config=models.VectorParams(size=embedder.get_sentence_embedding_dimension(), distance=models.Distance.COSINE), + ) + + points = [] + total_chunks = len(chunks) + for idx, chunk in enumerate(chunks, start=1): + # Qdrant point IDs must be positive integers + id_hash = int(hashlib.sha256(chunk["id"].encode("utf-8")).hexdigest(), 16) % (10**16) + vector = embedder.encode(chunk["text"]).tolist() + + points.append(models.PointStruct( + id=id_hash, + vector=vector, + payload={ + "text": chunk["text"], + "filename": chunk["id"].rsplit(".md_chunk", 1)[0], + "url": chunk.get("url", None) + } + )) + print(f"[{idx}/{total_chunks}] Подготовлен чанк: {chunk['id']} -> ID: {id_hash}") + + batch_size = 100 + for i in range(0, total_chunks, batch_size): + batch = points[i : i + batch_size] + client.upsert(collection_name=collection_name, points=batch) + print(f"Записан батч {(i // batch_size) + 1}, содержащий {len(batch)} точек, всего записано: {min(i + batch_size, total_chunks)}/{total_chunks}") + + print(f"Завершена запись всех {total_chunks} чанков в коллекцию '{collection_name}'.") + +if __name__ == "__main__": + print(f"Инициализация...") + parser = argparse.ArgumentParser(description="Скрипт векторизаци данных для Qdrant") + parser.add_argument("--input_dir", type=str, default="input_md", help="Директория с Markdown-файлами для чтения") + parser.add_argument("--chunk_size", type=int, default=500, help="Размер чанка") + parser.add_argument("--chunk_overlap", type=int, default=100, help="Размер перекрытия") + parser.add_argument("--embedding_model", type=str, default="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2", help="Модель эмбеддинга") + parser.add_argument("--qdrant_host", type=str, default="localhost", help="Адрес хоста Qdrant") + parser.add_argument("--qdrant_port", type=int, default=6333, help="Номер порта Qdrant") + args = parser.parse_args() + + documents = load_markdown_files(args.input_dir) + print(f"Найдено документов: {len(documents)}") + + print(f"Подготовка чанков...") + chunks = chunk_text(documents, args.chunk_size, args.chunk_overlap) + print(f"Создано чанков: {len(chunks)} ({args.chunk_size}/{args.chunk_overlap})") + + embed_and_upload(chunks, args.embedding_model, args.qdrant_host, args.qdrant_port) diff --git a/up b/up index 54ac51d..f56e3e1 100755 --- a/up +++ b/up @@ -1,6 +1,8 @@ #!/bin/bash docker compose up -d --build --remove-orphans -echo "* Ollama доступен по адресу: localhost:11434" -echo "* Open WebUI доступен по адресу: http://localhost:9999/" -echo "* Для остановки контейнеров выполните ./down" +source .env +echo "* Ollama доступен по адресу: localhost:$OLLAMA_PORT" +echo "* Open WebUI доступен по адресу: http://localhost:$QDRANT_PORT/" +echo "* Qdrant доступен по адресу: localhost:$OWEBUI_PORT" +echo "Для остановки контейнеров выполните ./down"