Почти полная переработка всего rag
- включение qdrant в контур - использование нормальной эмб-модели - векторизация текста - README и туча мелочей
This commit is contained in:
@@ -4,3 +4,8 @@ CONF_URL=
|
|||||||
# Имя пользователя и его пароль для авторизации
|
# Имя пользователя и его пароль для авторизации
|
||||||
CONF_USERNAME=
|
CONF_USERNAME=
|
||||||
CONF_PASSWORD=
|
CONF_PASSWORD=
|
||||||
|
|
||||||
|
# Порты сервисов на хосте
|
||||||
|
OLLAMA_PORT=11434
|
||||||
|
QDRANT_PORT=6333
|
||||||
|
OWEBUI_PORT=9999
|
||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,14 +1,12 @@
|
|||||||
/.data/*
|
/.data/*
|
||||||
/rag/input_html*/*
|
/rag/input_html/*
|
||||||
/rag/output_md*/*
|
/rag/input_md/*
|
||||||
/rag/ready_rag*/*
|
/rag/sys_prompt.txt
|
||||||
/rag/venv
|
|
||||||
|
.old/
|
||||||
|
.venv/
|
||||||
|
|
||||||
.env
|
.env
|
||||||
*.html
|
*.html
|
||||||
*.pdf
|
|
||||||
*.sqlite*
|
|
||||||
*.bin
|
|
||||||
*.pickle
|
|
||||||
|
|
||||||
!.gitkeep
|
!.gitkeep
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
```
|
```
|
||||||
./
|
./
|
||||||
├── models/ # Директория со скриптами установки моделей ollama
|
├── models/ # Директория со скриптами установки моделей ollama
|
||||||
├── rag/ # Директория для подготовки RAG
|
├── rag/ # Директория для работы с RAG
|
||||||
├── up # Скрипт для запуска ollama + open-webui
|
├── up # Скрипт для запуска ollama + open-webui
|
||||||
├── down # Скрипт для остановки ollama + open-webui
|
├── down # Скрипт для остановки ollama + open-webui
|
||||||
├── ollama # Скрипт для выполнения произвольных команд ollama
|
├── ollama # Скрипт для выполнения произвольных команд ollama
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
* python, venv, pip
|
* python, venv, pip
|
||||||
* [docker](https://docker.com)
|
* [docker](https://docker.com)
|
||||||
* [ollama](https://ollama.com)
|
* [ollama](https://ollama.com)
|
||||||
|
* [qdrant](https://qdrant.tech)
|
||||||
* [open-webui](https://docs.openwebui.com)
|
* [open-webui](https://docs.openwebui.com)
|
||||||
|
|
||||||
## Как использовать
|
## Как использовать
|
||||||
|
|||||||
21
compose.yml
21
compose.yml
@@ -1,21 +1,32 @@
|
|||||||
version: '3.8'
|
|
||||||
services:
|
services:
|
||||||
ai-ollama:
|
ai-ollama:
|
||||||
image: ollama/ollama
|
|
||||||
container_name: ai-ollama
|
container_name: ai-ollama
|
||||||
|
image: ollama/ollama
|
||||||
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./.data/ollama:/root/.ollama
|
- ./.data/ollama:/root/.ollama
|
||||||
ports:
|
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"
|
restart: "no"
|
||||||
|
|
||||||
ai-webui:
|
ai-webui:
|
||||||
image: ghcr.io/open-webui/open-webui:main
|
|
||||||
container_name: ai-webui
|
container_name: ai-webui
|
||||||
|
image: ghcr.io/open-webui/open-webui:main
|
||||||
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./.data/webui:/app/backend/data
|
- ./.data/webui:/app/backend/data
|
||||||
ports:
|
ports:
|
||||||
- "9999:8080"
|
- "${OWEBUI_PORT:-9999}:8080"
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
509
rag/3_rag.py
509
rag/3_rag.py
@@ -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()
|
|
||||||
260
rag/README.md
260
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?
|
||||||
|
|
||||||
RAG (Retrieval-Augmented Generation) — это архитектура, которая расширяет возможности генеративных языковых моделей (LLM) за счет интеграции внешних источников знаний.
|
RAG (Retrieval-Augmented Generation) — это архитектура, которая расширяет возможности генеративных языковых моделей (LLM) за счет интеграции внешних источников знаний.
|
||||||
Вместо того чтобы полагаться исключительно на информацию, полученную во время обучения, RAG сначала извлекает релевантные фрагменты из внешней базы знаний, а затем использует их для генерации более точных и информативных ответов.
|
Вместо того, чтобы полагаться исключительно на информацию, полученную во время обучения, RAG сначала извлекает релевантные фрагменты из внешней базы знаний, а затем использует их для генерации более точных и информативных ответов.
|
||||||
|
|
||||||
Основные компоненты RAG:
|
Основные шаги подготовки RAG:
|
||||||
- **Индексация**: Документы преобразуются в векторные представления (эмбеддинги) и сохраняются в векторной базе данных
|
- **Индексация**: Документы преобразуются в векторные представления (эмбеддинги) и сохраняются в векторной базе данных
|
||||||
- **Поиск**: При поступлении запроса система ищет наиболее релевантные фрагменты из индексированной базы
|
- **Поиск**: При поступлении запроса система ищет наиболее релевантные фрагменты из индексированной базы
|
||||||
- **Генерация**: Найденные фрагменты используются как контекст для генерации ответа с помощью языковой модели
|
- **Генерация**: Найденные фрагменты используются как контекст для генерации ответа с помощью языковой модели
|
||||||
@@ -16,183 +32,189 @@ RAG (Retrieval-Augmented Generation) — это архитектура, кото
|
|||||||
- Дает возможность проверить источник информации в сгенерированном ответе
|
- Дает возможность проверить источник информации в сгенерированном ответе
|
||||||
- Может работать с проприетарными или конфиденциальными данными без дообучения модели
|
- Может работать с проприетарными или конфиденциальными данными без дообучения модели
|
||||||
|
|
||||||
Этот проект представляет собой систему RAG, которая позволяет преобразовывать документацию из Confluence в формат, пригодный для работы с локальной Ollama, и задавать вопросы по содержимому документации.
|
|
||||||
|
|
||||||
## Структура проекта
|
## Структура проекта
|
||||||
|
|
||||||
```
|
```
|
||||||
rag/
|
rag/
|
||||||
├── .env.example # Пример файла конфигурации для подключения к Confluence
|
├── input_html/ # Входные файлы HTML, загруженные из Confluence
|
||||||
├── 1_download_page.sh # Скрипт для загрузки страниц из Confluence
|
├── input_md/ # Входные (конвертированные) файлы Markdown
|
||||||
├── 2_html_to_md.py # Скрипт конвертации HTML в Markdown
|
├── download.sh # Скрипт для загрузки страниц из Confluence
|
||||||
├── 3_rag.py # Основной скрипт RAG системы
|
├── convert.py # Скрипт конвертации HTML в Markdown
|
||||||
├── quickstart.sh # Скрипт быстрого запуска всего процесса
|
├── vectorize.py # Скрипт векторизации Markdown
|
||||||
├── input_html/ # Входные HTML файлы (загруженные из Confluence)
|
├── rag.py # Основной скрипт RAG системы
|
||||||
├── output_md/ # Конвертированные Markdown файлы
|
├── clear.sh # Скрипт очистки html/md файлов
|
||||||
├── ready_rag/ # Готовая векторная база данных для RAG
|
└── README.md # Этот файл
|
||||||
└── README.md # Этот файл
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
* bash
|
* bash
|
||||||
* python, venv, pip
|
* python, venv, pip
|
||||||
* [docker](https://docker.com)
|
|
||||||
* [ollama](https://ollama.com)
|
* [ollama](https://ollama.com)
|
||||||
|
* [qdrant](https://qdrant.tech)
|
||||||
* [open-webui](https://docs.openwebui.com)
|
* [open-webui](https://docs.openwebui.com)
|
||||||
|
|
||||||
## Предварительные требования
|
## Предварительные требования
|
||||||
|
|
||||||
1. Установить ПО: `curl`, `jq`, `python3`, `pip`
|
1. Запустить среду из корня репозитория: [../README.md](../README.md)
|
||||||
2. Установить зависимости python:
|
2. Установить ПО: `curl`, `jq`, `python3`, `pip`
|
||||||
|
3. Установить зависимости python:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
source venv/bin/activate
|
python3 -m venv .venv
|
||||||
pip install chromadb numpy requests beautifulsoup4
|
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 генеративную модель -- читай ниже.
|
||||||
4. Установить модели Ollama (рекомендуется):
|
|
||||||
- `nomic-embed-text:latest` — для эмбеддингов
|
|
||||||
(установить через `../models/nomic-embed-text/latest`)
|
|
||||||
- `phi4-mini:3.8b` или другая подходящая модель — для генерации ответов
|
|
||||||
(установить через `../models/phi4/mini:3.8b`)
|
|
||||||
|
|
||||||
## Настройка
|
## Подготовка данных
|
||||||
|
|
||||||
1. Создайте файл конфигурации на основе примера: `cp .env.example .env`
|
### Выгрузка из Confluence
|
||||||
2. Отредактируйте файл `.env`, указав свои данные
|
|
||||||
|
|
||||||
Файл используется только для подключения к Confluence.
|
Открыть страницу (раздел) Confluence, который необходимо получить.
|
||||||
|
Например, `https://conf.company.ltd/pages/viewpage.action?pageId=123456789`.
|
||||||
|
|
||||||
## Использование
|
Скопировать значение `pageId` и подставить в команду `./download.sh <pageId>`.
|
||||||
|
Например,
|
||||||
### Способ 1: Быстрый запуск (рекомендуется)
|
|
||||||
|
|
||||||
Запустите скрипт быстрого старта, указав ID страницы Confluence:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd rag
|
./download.sh 123456789
|
||||||
./quickstart.sh 123456789
|
|
||||||
```
|
```
|
||||||
|
|
||||||
где `123456789` - ID страницы Confluence, которую вы хотите обработать.
|
В результате сама страница и все её дочерние будут сохранены в директорию `./input_html/`.
|
||||||
|
Файлы будут названы по заголовкам страниц.
|
||||||
|
|
||||||
Скрипт автоматически:
|
> [!IMPORTANT]
|
||||||
- Создаст виртуальное окружение Python
|
> В начале каждого файла будет сохранена исходная ссылка в формате `@@https://conf.company.ltd/pages/viewpage.action?pageId=123456789@@`.
|
||||||
- Установит необходимые зависимости
|
> Это сделано для того, чтобы в диалоге с моделью источники информации отображались в виде ссылок, а не названий файлов.
|
||||||
- Загрузит страницу и все дочерние страницы
|
|
||||||
- Конвертирует HTML в Markdown
|
|
||||||
- Построит векторную базу данных
|
|
||||||
- Запустит интерактивный режим чата
|
|
||||||
|
|
||||||
### Способ 2: Пошаговая настройка
|
> [!IMPORTANT]
|
||||||
|
> Confluence не позволяет получить готовые макросы в HTML-виде.
|
||||||
|
> В файлах будет находиться HTML-текст с готовыми к загрузками макросами, но не загруженными.
|
||||||
|
> Хотя в них часто есть очень полезная информация, но её приходится выбрасывать просто потому, что загрузка макросов происходит при загрузке страницы браузером.
|
||||||
|
> Поэтому, например, содержания, списки дочерних страниц, встраиваемые диаграммы и пр. плюшечки будут вырезаны.
|
||||||
|
>
|
||||||
|
> Да, можно запросить страницы простым curl/wget, но (1) см. предыдущее замечание и (2) даже после очистки HTML-тегов в тексте останется очень много мусора (меню, футер, навигация...)
|
||||||
|
|
||||||
#### 1. Загрузка страниц из Confluence
|
### Конвертация страниц в Markdown
|
||||||
|
|
||||||
|
Этот формат наиболее хорошо подходит для цитирования, потому что не содержит лишних символов, которые будут мешать хорошей токенизации и векторизации.
|
||||||
|
|
||||||
|
Для конвертации следует выполнить команду:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./1_download_page.sh 123456789
|
python3 convert.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Этот скрипт:
|
В результате все html-файлы будут сохранены в директорию `./input_md/`.
|
||||||
- Загружает указанную страницу и все её дочерние страницы
|
Файлы будут названы по заголовкам страниц, внутри также сохранится ссылка на исходную страницу `@@...@@`.
|
||||||
- Сохраняет HTML-файлы в директорию `input_html/`
|
|
||||||
- Рекурсивно обрабатывает всю иерархию страниц
|
|
||||||
|
|
||||||
#### 2. Конвертация HTML в Markdown
|
Для получения справки по скрипту выполни команду:
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 convert.py --help
|
||||||
|
```
|
||||||
|
|
||||||
|
### Векторизация (индексирование)
|
||||||
|
|
||||||
|
Файлы `./input_md/*.md` должны быть проиндексированы.
|
||||||
|
|
||||||
|
Для того, чтобы проиндексировать Markdown-документы, выполнить команду:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 2_html_to_md.py
|
python3 vectorize.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Этот скрипт:
|
Весь текст делится на отрезки (чанки) с некоторым перекрытием.
|
||||||
- Обрабатывает все HTML-файлы в директории `input_html/`
|
Это делается для оптимизации количества информации, которое будет делиться на токены.
|
||||||
- Конвертирует их в Markdown с сохранением структуры
|
Перекрытие обеспечивает смысловую связь между чанками.
|
||||||
- Сохраняет результаты в директории `output_md/`
|
|
||||||
- Очищает от Confluence-специфичной разметки
|
|
||||||
|
|
||||||
#### 3. Построение RAG базы данных
|
Каждый чанк разбивается на токены, которые в виде векторов сохраняются в векторную СУБД Qdrant.
|
||||||
|
При работе RAG, близость векторов токенов обеспечивает наибольшее смысловое совпадение разных слов => чанков => предложений => абзацев => документов.
|
||||||
|
Как следствие:
|
||||||
|
- наибольшее смысловое совпадение с вопросом пользователя;
|
||||||
|
- молниеносный поиск по индексу чанков (частям документов);
|
||||||
|
- корректное насыщение контекста для генеративной модели.
|
||||||
|
|
||||||
```bash
|
Впоследствии embedding-модель будет встраивать эти данные в диалог с генеративной моделью.
|
||||||
python3 3_rag.py --action build
|
Каждый запрос сначала будет обрабатывать именно она, находя подходящие по векторам документы, и подставлять их в контекст генеративной модели.
|
||||||
|
Последняя будет всего лишь генерировать ответ, опираясь на предоставленные из документов данные, ссылаясь на них в ответе.
|
||||||
|
|
||||||
|
Для получения справки по скрипту выполни команду:
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 vectorize.py --help
|
||||||
```
|
```
|
||||||
|
|
||||||
Этот скрипт:
|
## Запуск
|
||||||
- Создает векторную базу данных на основе Markdown-файлов
|
|
||||||
- Генерирует эмбеддинги с помощью Ollama
|
|
||||||
- Сохраняет базу данных в директории `ready_rag/`
|
|
||||||
|
|
||||||
#### 4. Взаимодействие с RAG системой
|
Для работы с RAG в интерактивном режиме (симуляция диалога) следует выполнить команду:
|
||||||
|
|
||||||
```bash
|
```
|
||||||
python3 3_rag.py --action interactive
|
python3 rag.py --interactive
|
||||||
```
|
```
|
||||||
|
|
||||||
В интерактивном режиме:
|
> [!IMPORTANT]
|
||||||
- Введите свой вопрос и нажмите Enter
|
> Модель не запоминает ничего, поскольку сам диалог не попадает в контекст!
|
||||||
- Система найдет релевантные документы и сгенерирует ответ
|
> В целом, это похоже на гуглёж.
|
||||||
- Введите `exit` для выхода
|
|
||||||
- Введите `stats` для просмотра статистики
|
|
||||||
|
|
||||||
Также можно выполнить одиночный запрос:
|
Для разового запуска RAG следует выполнить команду:
|
||||||
|
|
||||||
```bash
|
```
|
||||||
python3 3_rag.py --action query --question "Ваш вопрос здесь"
|
python3 rag.py --query "твой запрос здесь"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Конфигурация
|
Для получения справки по скрипту выполни команду:
|
||||||
|
|
||||||
Вы можете настроить параметры RAG системы через аргументы командной строки:
|
```
|
||||||
|
python3 rag.py --help
|
||||||
```bash
|
|
||||||
python3 3_rag.py \
|
|
||||||
--md-folder output_md \
|
|
||||||
--embed-model nomic-embed-text \
|
|
||||||
--chat-model phi4-mini:3.8b \
|
|
||||||
--results 5
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Доступные параметры:
|
### Кастомный системный промпт
|
||||||
- `--md-folder`: Папка с Markdown файлами (по умолчанию: `output_md`)
|
|
||||||
- `--embed-model`: Модель для генерации эмбеддингов (по умолчанию: `nomic-embed-text`)
|
|
||||||
- `--chat-model`: Модель для генерации ответов (по умолчанию: `phi4-mini:3.8b`)
|
|
||||||
- `--results`: Количество возвращаемых результатов (по умолчанию: `10`)
|
|
||||||
-
|
|
||||||
## Пример использования
|
|
||||||
|
|
||||||
1. Загрузите страницу документации:
|
Если хочется уточнить роль генеративной модели, можно создать файл `sys_prompt.txt` и прописать туда всё необходимое, учитывая следующие правила:
|
||||||
```bash
|
|
||||||
./1_download_page.sh 123456789
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Конвертируйте в Markdown:
|
1. Шаблон `{{context}}` будет заменён на цитаты документов, найденные в qdrant
|
||||||
```bash
|
2. Шаблон `{{query}}` будет заменён на запрос пользователя
|
||||||
python3 2_html_to_md.py
|
3. Если этих двух шаблонов не будет в промпте, результаты будут непредсказуемыми
|
||||||
```
|
4. Каждая цитата в списке цитат формируется в формате:
|
||||||
|
```
|
||||||
|
--- Source X ---
|
||||||
|
Lorem ipsum dolor sit amet
|
||||||
|
<пустая строка>
|
||||||
|
```
|
||||||
|
5. Если в этой директории нет файла `sys_prompt.txt`, то будет применён промпт по умолчанию (см. функцию `generate_answer()`).
|
||||||
|
|
||||||
3. Постройте RAG базу:
|
Посмотреть полный промпт можно указав аргумент `--show_prompt` при вызове `rag.py`.
|
||||||
```bash
|
|
||||||
python3 3_rag.py --action build
|
|
||||||
```
|
|
||||||
|
|
||||||
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
9
rag/TODO.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Бэклог
|
||||||
|
|
||||||
|
* [ ] Описать подготовку знаний в Open WebUI
|
||||||
|
* [ ] Обработка pdf, json, ...
|
||||||
|
* [ ] Ранжировние результатов
|
||||||
|
* [ ] Режим диалога (запоминание запросов и ответов)
|
||||||
|
* [ ] API
|
||||||
|
* [ ] Telegram-бот
|
||||||
|
* [ ] ...
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
rm -rf ./input_html/*.html
|
rm -rf ./input_html/*.html
|
||||||
rm -rf ./input_pdf/*.pdf
|
rm -rf ./input_md/*.md
|
||||||
rm -rf ./output_md/*.md
|
|
||||||
rm -rf ./ready_rag/*
|
|
||||||
touch ./ready_rag/.gitkeep
|
|
||||||
|
|||||||
25
rag/convert.py
Normal file
25
rag/convert.py
Normal 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)
|
||||||
@@ -19,7 +19,7 @@ source .env
|
|||||||
PAGE_ID="$1"
|
PAGE_ID="$1"
|
||||||
API_ENDPOINT="${CONF_URL}/rest/api/content/${PAGE_ID}?expand=body.view,children.page"
|
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"
|
[ ! -d "$OUTPUT_PATH" ] && mkdir -p "$OUTPUT_PATH"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
@@ -37,9 +37,6 @@ if [ -n "$error_message" ] && [ "$error_message" != "null" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
output_path="./input_html"
|
|
||||||
[ ! -d "$output_path" ] && mkdir -p "$output_path"
|
|
||||||
|
|
||||||
title=$(echo "$response" | jq -r .title)
|
title=$(echo "$response" | jq -r .title)
|
||||||
content=$(echo "$response" | jq -r .body.view.value)
|
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//href=\"\//href=\"$CONF_URL}
|
||||||
content=${content//src=\"\//src=\"$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"
|
echo "Сохранено: $output_path/$title.html"
|
||||||
|
|
||||||
child_ids=$(echo "$response" | jq -r '.children.page.results[]?.id' 2>/dev/null)
|
child_ids=$(echo "$response" | jq -r '.children.page.results[]?.id' 2>/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
|
|
||||||
190
rag/rag.py
Normal file
190
rag/rag.py
Normal 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()
|
||||||
106
rag/vectorize.py
Normal file
106
rag/vectorize.py
Normal 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
8
up
@@ -1,6 +1,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
docker compose up -d --build --remove-orphans
|
docker compose up -d --build --remove-orphans
|
||||||
echo "* Ollama доступен по адресу: localhost:11434"
|
source .env
|
||||||
echo "* Open WebUI доступен по адресу: http://localhost:9999/"
|
echo "* Ollama доступен по адресу: localhost:$OLLAMA_PORT"
|
||||||
echo "* Для остановки контейнеров выполните ./down"
|
echo "* Open WebUI доступен по адресу: http://localhost:$QDRANT_PORT/"
|
||||||
|
echo "* Qdrant доступен по адресу: localhost:$OWEBUI_PORT"
|
||||||
|
echo "Для остановки контейнеров выполните ./down"
|
||||||
|
|||||||
Reference in New Issue
Block a user