1
0

Почти полная переработка всего rag

- включение qdrant в контур
- использование нормальной эмб-модели
- векторизация текста
- README и туча мелочей
This commit is contained in:
2025-08-25 01:55:46 +08:00
parent c6e498a0c8
commit a9328b4681
19 changed files with 509 additions and 1075 deletions

View File

@@ -4,3 +4,8 @@ CONF_URL=
# Имя пользователя и его пароль для авторизации
CONF_USERNAME=
CONF_PASSWORD=
# Порты сервисов на хосте
OLLAMA_PORT=11434
QDRANT_PORT=6333
OWEBUI_PORT=9999

14
.gitignore vendored
View File

@@ -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

View File

@@ -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)
## Как использовать

View File

@@ -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"

View File

@@ -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 <pageId>"
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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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 <pageId>`.
Например,
```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.

9
rag/TODO.md Normal file
View File

@@ -0,0 +1,9 @@
# Бэклог
* [ ] Описать подготовку знаний в Open WebUI
* [ ] Обработка pdf, json, ...
* [ ] Ранжировние результатов
* [ ] Режим диалога (запоминание запросов и ответов)
* [ ] API
* [ ] Telegram-бот
* [ ] ...

View File

@@ -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

25
rag/convert.py Normal file
View File

@@ -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)

View File

@@ -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 "<html><body>Страница: <a href=\"$CONF_URL/pages/viewpage.action?pageId=$PAGE_ID\">$CONF_URL/pages/viewpage.action?pageId=$PAGE_ID</a><br><br><h1>$title</h1>$content</body></html>" > "$path"
url="$CONF_URL/pages/viewpage.action?pageId=$PAGE_ID"
echo -e "@@$url@@\n<br><html><body>Исходная страница: <a href=$url>$url</a><br><br><h1>$title</h1>$content</body></html>" > "$path"
echo "Сохранено: $output_path/$title.html"
child_ids=$(echo "$response" | jq -r '.children.page.results[]?.id' 2>/dev/null)

View File

View File

@@ -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

190
rag/rag.py Normal file
View File

@@ -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()

View File

106
rag/vectorize.py Normal file
View File

@@ -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)

8
up
View File

@@ -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"