Compare commits
10 Commits
77ba817f14
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
f5d3229b7c
|
|||
|
74b0f4ba55
|
|||
|
4748403378
|
|||
|
f1aa9f2324
|
|||
|
435d4dfbc2
|
|||
|
87b65155d5
|
|||
|
1f54ab0409
|
|||
|
0106d157d3
|
|||
|
bb32153fc1
|
|||
|
1413933521
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,8 +1,10 @@
|
|||||||
|
/.vscode/launch.json
|
||||||
/.data/*
|
/.data/*
|
||||||
/rag/input_html/*
|
/rag/input_html/*
|
||||||
/rag/data/*
|
/rag/data/*
|
||||||
/rag/sys_prompt.txt
|
/rag/sys_prompt.txt
|
||||||
/rag/chats/*.md
|
/rag/chats/*.md
|
||||||
|
/rag/prompts/*
|
||||||
|
|
||||||
.old/
|
.old/
|
||||||
.venv/
|
.venv/
|
||||||
|
|||||||
@@ -10,17 +10,14 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "${workspaceFolder}/rag/rag.py",
|
"program": "${workspaceFolder}/rag/rag.py",
|
||||||
"args": [
|
"args": [
|
||||||
|
"--verbose",
|
||||||
"--show-stats",
|
"--show-stats",
|
||||||
"--interactive",
|
// "--interactive",
|
||||||
"--use-rank",
|
"--use-rank",
|
||||||
// "--stream",
|
// "--stream",
|
||||||
"--show-prompt",
|
"--show-prompt",
|
||||||
"--sys-prompt",
|
|
||||||
"${workspaceFolder}/rag/lis-sp.md",
|
|
||||||
"--qdrant-collection",
|
"--qdrant-collection",
|
||||||
"rag-2000-300",
|
"rag-2000-300"
|
||||||
"--query",
|
|
||||||
"привет"
|
|
||||||
],
|
],
|
||||||
"console": "integratedTerminal"
|
"console": "integratedTerminal"
|
||||||
}
|
}
|
||||||
209
README.md
209
README.md
@@ -37,112 +37,113 @@
|
|||||||
2. Запустить `./ollama run <название модели>` для диалога в терминале
|
2. Запустить `./ollama run <название модели>` для диалога в терминале
|
||||||
3. Открыть веб-морду по адресу [localhost:9999](http://localhost:9999) для более богатого функционала
|
3. Открыть веб-морду по адресу [localhost:9999](http://localhost:9999) для более богатого функционала
|
||||||
|
|
||||||
|
<a id="models"></a>
|
||||||
<details>
|
<details>
|
||||||
<summary>Полный список лёгких и средних моделей, которые можно попробовать для разных целей</summary>
|
<summary>Полный список лёгких и средних моделей, которые можно попробовать для разных целей</summary>
|
||||||
|
|
||||||
```
|
```
|
||||||
codegemma:2b
|
codegemma:2b
|
||||||
codegemma:7b
|
codegemma:7b
|
||||||
codellama:7b
|
codellama:7b
|
||||||
codellama:13b
|
codellama:13b
|
||||||
codellama:34b
|
codellama:34b
|
||||||
codeqwen:1.5b
|
codeqwen:1.5b
|
||||||
codeqwen:7b
|
codeqwen:7b
|
||||||
codestral:22b
|
codestral:22b
|
||||||
deepcoder:1.5b
|
deepcoder:1.5b
|
||||||
deepcoder:14b
|
deepcoder:14b
|
||||||
deepseek-coder:1.3b
|
deepseek-coder:1.3b
|
||||||
deepseek-coder:6.7b
|
deepseek-coder:6.7b
|
||||||
deepseek-coder:33b
|
deepseek-coder:33b
|
||||||
deepseek-coder-v2:16b
|
deepseek-coder-v2:16b
|
||||||
deepseek-r1:1.5b
|
deepseek-r1:1.5b
|
||||||
deepseek-r1:7b
|
deepseek-r1:7b
|
||||||
deepseek-r1:8b
|
deepseek-r1:8b
|
||||||
deepseek-r1:14b
|
deepseek-r1:14b
|
||||||
deepseek-r1:32b
|
deepseek-r1:32b
|
||||||
devstral:24b
|
devstral:24b
|
||||||
dolphin3:8b
|
dolphin3:8b
|
||||||
gemma:2b
|
gemma:2b
|
||||||
gemma:7b
|
gemma:7b
|
||||||
gemma3:1b
|
gemma3:1b
|
||||||
gemma3:4b
|
gemma3:4b
|
||||||
gemma3:12b
|
gemma3:12b
|
||||||
gemma3:27b
|
gemma3:27b
|
||||||
gemma3:270m
|
gemma3:270m
|
||||||
gemma3n:e2b
|
gemma3n:e2b
|
||||||
gemma3n:e4b
|
gemma3n:e4b
|
||||||
gpt-oss:20b
|
gpt-oss:20b
|
||||||
granite-code:3b
|
granite-code:3b
|
||||||
granite-code:8b
|
granite-code:8b
|
||||||
granite-code:20b
|
granite-code:20b
|
||||||
granite-code:34b
|
granite-code:34b
|
||||||
llama2:7b
|
llama2:7b
|
||||||
llama2:13b
|
llama2:13b
|
||||||
llama3:8b
|
llama3:8b
|
||||||
llama3.1:8b
|
llama3.1:8b
|
||||||
llama3.2:1b
|
llama3.2:1b
|
||||||
llama3.2:3b
|
llama3.2:3b
|
||||||
llava-llama3:8b
|
llava-llama3:8b
|
||||||
magistral:24b
|
magistral:24b
|
||||||
mistral:7b
|
mistral:7b
|
||||||
mistral-nemo:12b
|
mistral-nemo:12b
|
||||||
mistral-small:22b
|
mistral-small:22b
|
||||||
mistral-small:24b
|
mistral-small:24b
|
||||||
mixtral:8x7b
|
mixtral:8x7b
|
||||||
mxbai-embed-large:latest
|
mxbai-embed-large:latest
|
||||||
nomic-embed-text:latest
|
nomic-embed-text:latest
|
||||||
openthinker:7b
|
openthinker:7b
|
||||||
openthinker:32b
|
openthinker:32b
|
||||||
phi:2.7b
|
phi:2.7b
|
||||||
phi3:3.8b
|
phi3:3.8b
|
||||||
phi3:14b
|
phi3:14b
|
||||||
phi3:instruct
|
phi3:instruct
|
||||||
phi3:medium
|
phi3:medium
|
||||||
phi3:mini
|
phi3:mini
|
||||||
phi3.5:3.8b
|
phi3.5:3.8b
|
||||||
phi4:14b
|
phi4:14b
|
||||||
phi4-mini-reasoning:3.8b
|
phi4-mini-reasoning:3.8b
|
||||||
phi4-mini:3.8b
|
phi4-mini:3.8b
|
||||||
phi4-reasoning:14b
|
phi4-reasoning:14b
|
||||||
qwen:0.5b
|
qwen:0.5b
|
||||||
qwen:1.8b
|
qwen:1.8b
|
||||||
qwen:4b
|
qwen:4b
|
||||||
qwen:7b
|
qwen:7b
|
||||||
qwen:14b
|
qwen:14b
|
||||||
qwen:32b
|
qwen:32b
|
||||||
qwen2:0.5b
|
qwen2:0.5b
|
||||||
qwen2:1.5b
|
qwen2:1.5b
|
||||||
qwen2:7b
|
qwen2:7b
|
||||||
qwen2.5:0.5b
|
qwen2.5:0.5b
|
||||||
qwen2.5:1.5b
|
qwen2.5:1.5b
|
||||||
qwen2.5:3b
|
qwen2.5:3b
|
||||||
qwen2.5:7b
|
qwen2.5:7b
|
||||||
qwen2.5:14b
|
qwen2.5:14b
|
||||||
qwen2.5:32b
|
qwen2.5:32b
|
||||||
qwen2.5-coder:0.5b
|
qwen2.5-coder:0.5b
|
||||||
qwen2.5-coder:1.5b
|
qwen2.5-coder:1.5b
|
||||||
qwen2.5-coder:3b
|
qwen2.5-coder:3b
|
||||||
qwen2.5-coder:7b
|
qwen2.5-coder:7b
|
||||||
qwen2.5-coder:14b
|
qwen2.5-coder:14b
|
||||||
qwen2.5-coder:32b
|
qwen2.5-coder:32b
|
||||||
qwen3:0.6b
|
qwen3:0.6b
|
||||||
qwen3:1.7b
|
qwen3:1.7b
|
||||||
qwen3:4b
|
qwen3:4b
|
||||||
qwen3:8b
|
qwen3:8b
|
||||||
qwen3:14b
|
qwen3:14b
|
||||||
qwen3:30b
|
qwen3:30b
|
||||||
qwen3:32b
|
qwen3:32b
|
||||||
qwen3-coder:30b
|
qwen3-coder:30b
|
||||||
qwq:32b
|
qwq:32b
|
||||||
smollm2:1.7m
|
smollm2:1.7m
|
||||||
smollm2:135m
|
smollm2:135m
|
||||||
smollm2:360m
|
smollm2:360m
|
||||||
stable-code:3b
|
stable-code:3b
|
||||||
stable-code:instruct
|
stable-code:instruct
|
||||||
starcoder2:3b
|
starcoder2:3b
|
||||||
starcoder2:7b
|
starcoder2:7b
|
||||||
starcoder2:15b
|
starcoder2:15b
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
41
compose.yml
41
compose.yml
@@ -9,24 +9,25 @@ services:
|
|||||||
- "${OLLAMA_PORT:-11434}:11434"
|
- "${OLLAMA_PORT:-11434}:11434"
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
ai-qdrant:
|
# ai-qdrant:
|
||||||
container_name: ai-qdrant
|
# container_name: ai-qdrant
|
||||||
image: qdrant/qdrant
|
# image: qdrant/qdrant
|
||||||
env_file: .env
|
# env_file: .env
|
||||||
ports:
|
# ports:
|
||||||
- "${QDRANT_PORT:-6333}:6333"
|
# - "${QDRANT_PORT:-6333}:6333"
|
||||||
volumes:
|
# volumes:
|
||||||
- ./.data/qdrant/storage:/qdrant/storage
|
# - ./.data/qdrant/storage:/qdrant/storage
|
||||||
restart: "no"
|
# restart: "no"
|
||||||
|
# profiles: ["rag"]
|
||||||
|
|
||||||
ai-webui:
|
# ai-webui:
|
||||||
container_name: ai-webui
|
# container_name: ai-webui
|
||||||
image: ghcr.io/open-webui/open-webui:main
|
# image: ghcr.io/open-webui/open-webui:main
|
||||||
env_file: .env
|
# env_file: .env
|
||||||
volumes:
|
# volumes:
|
||||||
- ./.data/webui:/app/backend/data
|
# - ./.data/webui:/app/backend/data
|
||||||
ports:
|
# ports:
|
||||||
- "${OWEBUI_PORT:-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,17 +0,0 @@
|
|||||||
# Чек-лист по построению RAG
|
|
||||||
|
|
||||||
* [ ] Определиться с форматом входных данных
|
|
||||||
* [ ] Очистить входные данные, обеспечив метаданными
|
|
||||||
* [ ] Подобрать модель эмбеддинга
|
|
||||||
* [ ] Подобрать размер чанка и перекрытия для эмбеддинга
|
|
||||||
* [ ] Подобрать место хранения (векторная СУБД)
|
|
||||||
* [ ] Подобрать модель ранжирования
|
|
||||||
* [ ] Подобрать модель генерации
|
|
||||||
* [ ] Подобрать для неё системный промпт (для встраивания найденных чанков, грамотного их цитирования)
|
|
||||||
* [ ] Подобрать параметры:
|
|
||||||
* [ ] top_k (количество чанков для поиска при эмбеддинге)
|
|
||||||
* [ ] top_n (остаток найденных чанков после ранжирования)
|
|
||||||
* [ ] temperature (степень фантазии)
|
|
||||||
* [ ] top_p (???)
|
|
||||||
* [ ] другие?
|
|
||||||
* [ ]
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
cd ..; ./up; cd -
|
cd ..; ./up; cd -
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install beautifulsoup4 markdownify sentence-transformers qdrant-client langchain transformers
|
pip install beautifulsoup4 markdownify sentence-transformers qdrant-client langchain transformers ollama
|
||||||
pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
|
pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
|
||||||
./download.sh 123456789 # <<== pageId страницы в Confluence
|
./download.sh 123456789 # <<== pageId страницы в Confluence
|
||||||
python3 convert.py
|
python3 convert.py
|
||||||
@@ -66,7 +66,7 @@ rag/
|
|||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
source ./venv/bin/activate
|
source ./venv/bin/activate
|
||||||
pip install beautifulsoup4 markdownify sentence-transformers qdrant-client langchain transformers
|
pip install beautifulsoup4 markdownify sentence-transformers qdrant-client langchain transformers ollama
|
||||||
pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
|
pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ python3 rag.py --help
|
|||||||
5. При вызове `rag.py` указать путь к файлу промпта, используя аргумент `--sys-prompt $путь_к_файлу`
|
5. При вызове `rag.py` указать путь к файлу промпта, используя аргумент `--sys-prompt $путь_к_файлу`
|
||||||
6. Если указанного файла не существует, то будет применён промпт по умолчанию.
|
6. Если указанного файла не существует, то будет применён промпт по умолчанию.
|
||||||
|
|
||||||
Посмотреть полный промпт можно указав аргумент `--show_prompt` при вызове `rag.py`.
|
Посмотреть полный промпт можно указав аргумент `--show-prompt` при вызове `rag.py`.
|
||||||
|
|
||||||
## Неплохие модели для экспериментов
|
## Неплохие модели для экспериментов
|
||||||
|
|
||||||
@@ -219,8 +219,8 @@ python3 rag.py --help
|
|||||||
|
|
||||||
### Ранжирование
|
### Ранжирование
|
||||||
|
|
||||||
- [`cross-encoder/ms-marco-MMarco-mMiniLMv2-L12-V1`](https://hf.co/cross-encoder/ms-marco-MMarco-mMiniLMv2-L12-V1) ☑️
|
- [`cross-encoder/ms-marco-MMarco-mMiniLMv2-L12-V1`](https://hf.co/cross-encoder/ms-marco-MMarco-mMiniLMv2-L12-V1)
|
||||||
- [`cross-encoder/ms-marco-MiniLM-L-6-v2`](https://hf.co/cross-encoder/ms-marco-MiniLM-L-6-v2)
|
- [`cross-encoder/ms-marco-MiniLM-L-6-v2`](https://hf.co/cross-encoder/ms-marco-MiniLM-L-6-v2) ☑️
|
||||||
- [`cross-encoder/ms-marco-TinyBERT-L-2-v2`](https://hf.co/cross-encoder/ms-marco-TinyBERT-L-2-v2)
|
- [`cross-encoder/ms-marco-TinyBERT-L-2-v2`](https://hf.co/cross-encoder/ms-marco-TinyBERT-L-2-v2)
|
||||||
- ...
|
- ...
|
||||||
|
|
||||||
@@ -229,7 +229,7 @@ python3 rag.py --help
|
|||||||
|
|
||||||
### Генеративные
|
### Генеративные
|
||||||
|
|
||||||
Перечислен список: по убыванию качества ответов и размера модели, по возрастанию скорости ответов на обычном домашнем ПК.
|
Список по убыванию качества ответов и размера модели, по возрастанию скорости ответов на обычном домашнем ПК.
|
||||||
|
|
||||||
- [`deepseek-r1:8b`](https://ollama.com/library/deepseek-r1) 🏋️🧠
|
- [`deepseek-r1:8b`](https://ollama.com/library/deepseek-r1) 🏋️🧠
|
||||||
- [`qwen3:8b`](https://ollama.com/library/qwen3) 🏋️🧠
|
- [`qwen3:8b`](https://ollama.com/library/qwen3) 🏋️🧠
|
||||||
@@ -241,6 +241,8 @@ python3 rag.py --help
|
|||||||
- [`gemma3n:e4b`](https://ollama.com/library/gemma3n)
|
- [`gemma3n:e4b`](https://ollama.com/library/gemma3n)
|
||||||
- [`gemma3n:e2b`](https://ollama.com/library/gemma3n)
|
- [`gemma3n:e2b`](https://ollama.com/library/gemma3n)
|
||||||
|
|
||||||
|
Также можно посмотреть на [эти модели](../README.md#models) или свои собственные.
|
||||||
|
|
||||||
## Дисклеймер
|
## Дисклеймер
|
||||||
|
|
||||||
Проект родился на энтузиазме из личного любопытства.
|
Проект родился на энтузиазме из личного любопытства.
|
||||||
@@ -250,8 +252,17 @@ python3 rag.py --help
|
|||||||
**Задачи:**
|
**Задачи:**
|
||||||
|
|
||||||
1. облегчить поиск информации о проекте среди почти 2000 тысяч документов в корпоративной Confluence, относящихся к нему;
|
1. облегчить поиск информации о проекте среди почти 2000 тысяч документов в корпоративной Confluence, относящихся к нему;
|
||||||
2. обеспечить минимум телодвижений для развёртывания RAG с нуля внутри команды.
|
2. обеспечить минимум телодвижений для развёртывания RAG с нуля внутри команды;
|
||||||
|
3. построить воспроизводимую среду для запуска проекта.
|
||||||
|
|
||||||
Здесь не было задачи сделать всё сложно и по красоте.
|
Здесь не было задачи сделать всё сложно и по красоте.
|
||||||
|
|
||||||
|
Этот проект -- пазл, который позволяет пошагово, по косточкам понять и настроить работу RAG.
|
||||||
|
|
||||||
Частично (в качестве агентов) в проекте участвовали модели семейств qwen, clause и chatgpt.
|
Частично (в качестве агентов) в проекте участвовали модели семейств qwen, clause и chatgpt.
|
||||||
|
|
||||||
|
## Дополнительные материалы
|
||||||
|
|
||||||
|
* https://github.com/ollama/ollama/blob/main/docs/api.md
|
||||||
|
* https://habr.com/ru/articles/881268/
|
||||||
|
* https://habr.com/ru/companies/oleg-bunin/articles/835910/
|
||||||
|
|||||||
505
rag/chat.py
Normal file
505
rag/chat.py
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
import os
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
from qdrant_client import QdrantClient
|
||||||
|
from sentence_transformers import SentenceTransformer, CrossEncoder
|
||||||
|
|
||||||
|
DEFAULT_CHAT_MODEL = "openchat:7b"
|
||||||
|
DEFAULT_EMBED_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
|
||||||
|
# DEFAULT_RANK_MODEL = "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1"
|
||||||
|
DEFAULT_RANK_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
||||||
|
# DEFAULT_RANK_MODEL = "cross-encoder/ms-marco-TinyBERT-L-2-v2"
|
||||||
|
DEFAULT_MD_FOLDER = "data"
|
||||||
|
DEFAULT_OLLAMA_URL = "http://localhost:11434"
|
||||||
|
DEFAULT_QDRANT_HOST = "localhost"
|
||||||
|
DEFAULT_QDRANT_PORT = 6333
|
||||||
|
DEFAULT_QDRANT_COLLECTION = "rag"
|
||||||
|
DEFAULT_TOP_K = 30
|
||||||
|
DEFAULT_USE_RANK = False
|
||||||
|
DEFAULT_TOP_N = 8
|
||||||
|
DEFAULT_VERBOSE = False
|
||||||
|
DEFAULT_SHOW_STATS = False
|
||||||
|
DEFAULT_STREAM = False
|
||||||
|
DEFAULT_INTERACTIVE = False
|
||||||
|
DEFAULT_SHOW_PROMPT = False
|
||||||
|
DEFAULT_MIN_RANK_SCORE = 0
|
||||||
|
|
||||||
|
class RagSystem:
|
||||||
|
def __init__(self,
|
||||||
|
ollama_url: str = DEFAULT_OLLAMA_URL,
|
||||||
|
qdrant_host: str = DEFAULT_QDRANT_HOST,
|
||||||
|
qdrant_port: int = DEFAULT_QDRANT_PORT,
|
||||||
|
embed_model: str = DEFAULT_EMBED_MODEL,
|
||||||
|
rank_model: str = DEFAULT_RANK_MODEL,
|
||||||
|
use_rank: bool = DEFAULT_USE_RANK,
|
||||||
|
chat_model: str = DEFAULT_CHAT_MODEL):
|
||||||
|
self.ollama_url = ollama_url
|
||||||
|
self.qdrant_host = qdrant_host
|
||||||
|
self.qdrant_port = qdrant_port
|
||||||
|
self.chat_model = chat_model
|
||||||
|
self.emb_model = SentenceTransformer(embed_model)
|
||||||
|
self.qdrant = QdrantClient(host=args.qdrant_host, port=args.qdrant_port)
|
||||||
|
self.use_rank = use_rank
|
||||||
|
if self.use_rank:
|
||||||
|
self.rank_model = CrossEncoder(rank_model)
|
||||||
|
self.conversation_history = []
|
||||||
|
|
||||||
|
self.load_chat_model()
|
||||||
|
|
||||||
|
def load_chat_model(self):
|
||||||
|
url = f"{self.ollama_url}/api/generate"
|
||||||
|
body = {"model": self.chat_model}
|
||||||
|
requests.post(url, json=body, timeout=600)
|
||||||
|
|
||||||
|
def search_qdrant(self, query: str, doc_count: int = DEFAULT_TOP_K, collection_name = DEFAULT_QDRANT_COLLECTION):
|
||||||
|
query_vec = self.emb_model.encode(query, show_progress_bar=False).tolist()
|
||||||
|
results = self.qdrant.query_points(
|
||||||
|
collection_name=collection_name,
|
||||||
|
query=query_vec,
|
||||||
|
limit=doc_count,
|
||||||
|
# score_threshold=0.5,
|
||||||
|
)
|
||||||
|
docs = []
|
||||||
|
for point in results.points:
|
||||||
|
docs.append({
|
||||||
|
"payload": point.payload,
|
||||||
|
"score": point.score,
|
||||||
|
})
|
||||||
|
return docs
|
||||||
|
|
||||||
|
def rank_documents(self, query: str, documents: list, top_n: int = DEFAULT_TOP_N, min_score: int = DEFAULT_MIN_RANK_SCORE):
|
||||||
|
if not self.use_rank:
|
||||||
|
return documents
|
||||||
|
|
||||||
|
pairs = [[query, doc["payload"]["text"]] for doc in documents]
|
||||||
|
scores = self.rank_model.predict(pairs)
|
||||||
|
|
||||||
|
ranked_docs = []
|
||||||
|
for i, doc in enumerate(documents):
|
||||||
|
score = float(scores[i])
|
||||||
|
doc["rank_score"] = score
|
||||||
|
if score >= min_score:
|
||||||
|
ranked_docs.append(doc)
|
||||||
|
|
||||||
|
ranked_docs.sort(key=lambda x: x['rank_score'], reverse=True)
|
||||||
|
return ranked_docs[:top_n]
|
||||||
|
|
||||||
|
def generate_answer(self, sys_prompt: str, user_prompt: str):
|
||||||
|
url = f"{self.ollama_url}/api/chat"
|
||||||
|
body = {
|
||||||
|
"model": self.chat_model,
|
||||||
|
# "system": sys_prompt,
|
||||||
|
# "prompt": user_prompt,
|
||||||
|
"messages": self.conversation_history,
|
||||||
|
"stream": False,
|
||||||
|
"options": {
|
||||||
|
"temperature": 0.5,
|
||||||
|
# "top_p": 0.2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(url, json=body, timeout=900)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return f"Ошибка генерации ответа: {response.status_code} {response.text}"
|
||||||
|
self.response = response.json()
|
||||||
|
return self.response["message"]["content"]
|
||||||
|
|
||||||
|
def generate_answer_stream(self, sys_prompt: str, user_prompt: str):
|
||||||
|
url = f"{self.ollama_url}/api/chat"
|
||||||
|
body = {
|
||||||
|
"model": self.chat_model,
|
||||||
|
# "system": sys_prompt,
|
||||||
|
# "prompt": user_prompt,
|
||||||
|
"messages": self.conversation_history,
|
||||||
|
"stream": True,
|
||||||
|
"options": {
|
||||||
|
"temperature": 0.5,
|
||||||
|
# "top_p": 0.2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp = requests.post(url, json=body, stream=True, timeout=900)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise RuntimeError(f"Ошибка генерации ответа: {resp.status_code} {resp.text}")
|
||||||
|
|
||||||
|
answer = ""
|
||||||
|
for chunk in resp.iter_lines():
|
||||||
|
if chunk:
|
||||||
|
try:
|
||||||
|
decoded_chunk = chunk.decode('utf-8')
|
||||||
|
data = json.loads(decoded_chunk)
|
||||||
|
if "response" in data:
|
||||||
|
yield data["response"]
|
||||||
|
answer += data["response"]
|
||||||
|
if "done" in data and data["done"] is True:
|
||||||
|
self.response = data
|
||||||
|
break
|
||||||
|
elif "error" in data:
|
||||||
|
answer += f" | Ошибка стриминга ответа: {data['error']}"
|
||||||
|
break
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
answer += f" | Ошибка конвертации чанка: {chunk.decode('utf-8')} - {e}"
|
||||||
|
except Exception as e:
|
||||||
|
answer += f" | Ошибка обработки чанка: {e}"
|
||||||
|
|
||||||
|
def get_prompt_eval_count(self):
|
||||||
|
if not self.response["prompt_eval_count"]:
|
||||||
|
return 0
|
||||||
|
return self.response["prompt_eval_count"]
|
||||||
|
|
||||||
|
def get_prompt_eval_duration(self):
|
||||||
|
if not self.response["prompt_eval_duration"]:
|
||||||
|
return 0
|
||||||
|
return self.response["prompt_eval_duration"] / (10 ** 9)
|
||||||
|
|
||||||
|
def get_eval_count(self):
|
||||||
|
if not self.response["eval_count"]:
|
||||||
|
return 0
|
||||||
|
return self.response["eval_count"]
|
||||||
|
|
||||||
|
def get_eval_duration(self):
|
||||||
|
if not self.response["eval_duration"]:
|
||||||
|
return 0
|
||||||
|
return self.response["eval_duration"] / (10 ** 9)
|
||||||
|
|
||||||
|
def get_total_duration(self):
|
||||||
|
if not self.response["total_duration"]:
|
||||||
|
return 0
|
||||||
|
return self.response["total_duration"] / (10 ** 9)
|
||||||
|
|
||||||
|
def get_tps(self):
|
||||||
|
eval_count = self.get_eval_count()
|
||||||
|
eval_duration = self.get_eval_duration()
|
||||||
|
if eval_count == 0 or eval_duration == 0:
|
||||||
|
return 0
|
||||||
|
return eval_count / eval_duration
|
||||||
|
|
||||||
|
class App:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
args: list = []
|
||||||
|
):
|
||||||
|
if not args.query and not args.interactive:
|
||||||
|
print("Ошибка: укажите запрос (--query) и/или используйте интерактивный режим (--interactive)")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
self.args = args
|
||||||
|
self.print_v(text=f"Включить интерактивный режим диалога: {args.interactive}")
|
||||||
|
self.print_v(text=f"Включить потоковый вывод: {args.stream}")
|
||||||
|
if self.is_custom_sys_prompt():
|
||||||
|
self.print_v(text=f"Системный промпт: {args.sys_prompt}")
|
||||||
|
else:
|
||||||
|
self.print_v(text=f"Системный промпт: по умолчанию")
|
||||||
|
self.print_v(text=f"Показать сист. промпт перед запросом: {args.show_prompt}")
|
||||||
|
self.print_v(text=f"Выводить служебные сообщения: {args.verbose}")
|
||||||
|
self.print_v(text=f"Выводить статистику об ответе: {args.show_stats}")
|
||||||
|
self.print_v(text=f"Адрес хоста Qdrant: {args.qdrant_host}")
|
||||||
|
self.print_v(text=f"Номер порта Qdrant: {args.qdrant_port}")
|
||||||
|
self.print_v(text=f"Название коллекции для поиска документов: {args.qdrant_collection}")
|
||||||
|
self.print_v(text=f"Ollama API URL: {args.ollama_url}")
|
||||||
|
self.print_v(text=f"Модель генерации Ollama: {args.chat_model}")
|
||||||
|
self.print_v(text=f"Модель эмбеддинга: {args.emb_model}")
|
||||||
|
self.print_v(text=f"Количество документов для поиска: {args.topk}")
|
||||||
|
self.print_v(text=f"Включить ранжирование: {args.use_rank}")
|
||||||
|
self.print_v(text=f"Модель ранжирования: {args.rank_model}")
|
||||||
|
self.print_v(text=f"Количество документов после ранжирования: {args.topn}")
|
||||||
|
self.init_rag()
|
||||||
|
|
||||||
|
def print_v(self, text: str = "\n"):
|
||||||
|
if self.args.verbose:
|
||||||
|
print(f"{text}")
|
||||||
|
|
||||||
|
def init_rag(self):
|
||||||
|
self.print_v(text="\nИнициализация моделей...")
|
||||||
|
self.rag = RagSystem(
|
||||||
|
ollama_url = self.args.ollama_url,
|
||||||
|
qdrant_host = self.args.qdrant_host,
|
||||||
|
qdrant_port = self.args.qdrant_port,
|
||||||
|
embed_model = self.args.emb_model,
|
||||||
|
rank_model = self.args.rank_model,
|
||||||
|
use_rank = self.args.use_rank,
|
||||||
|
chat_model = self.args.chat_model
|
||||||
|
)
|
||||||
|
self.print_v(text=f"Модели загружены. Если ответ плохой, переформулируйте запрос, укажите --chat-model или улучшите исходные данные RAG")
|
||||||
|
|
||||||
|
def init_query(self):
|
||||||
|
self.query = None
|
||||||
|
if args.interactive:
|
||||||
|
self.print_v(text="\nИНТЕРАКТИВНЫЙ РЕЖИМ")
|
||||||
|
self.print_v(text="Можете вводить запрос (или 'exit' для выхода)\n")
|
||||||
|
|
||||||
|
if self.args.query:
|
||||||
|
self.query = self.args.query.strip()
|
||||||
|
print(f">>> {self.query}")
|
||||||
|
elif args.interactive:
|
||||||
|
self.query = input(">>> ").strip()
|
||||||
|
|
||||||
|
def process_help(self):
|
||||||
|
print("<<< Команды итерактивного режима:")
|
||||||
|
print("save -- сохранить диалог в файл")
|
||||||
|
print("exit -- выход\n")
|
||||||
|
self.query = None
|
||||||
|
self.args.query = None
|
||||||
|
|
||||||
|
def process_save(self):
|
||||||
|
import datetime
|
||||||
|
timestamp = int(time.time())
|
||||||
|
dt = datetime.datetime.fromtimestamp(timestamp).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
filename = f"chats/chat-{timestamp}-{self.args.chat_model}.md"
|
||||||
|
|
||||||
|
markdown_content = f"# История диалога от {dt}\n\n"
|
||||||
|
markdown_content += f"## Параметры диалога\n"
|
||||||
|
markdown_content += f"```\nargs = {self.args}\n```\n"
|
||||||
|
markdown_content += f"```\nemb_model = {self.rag.emb_model}\n```\n"
|
||||||
|
markdown_content += f"```\nrank_model = {self.rag.rank_model}\n```\n"
|
||||||
|
|
||||||
|
for entry in self.rag.conversation_history:
|
||||||
|
if entry['role'] == 'user':
|
||||||
|
markdown_content += f"## Пользователь\n\n"
|
||||||
|
elif entry['role'] == 'assistant':
|
||||||
|
markdown_content += f"## Модель\n\n"
|
||||||
|
docs = self.rag.prepare_ctx_sources(entry['docs']).replace("```", "")
|
||||||
|
markdown_content += f"```\n{docs}\n```\n\n"
|
||||||
|
markdown_content += f"{entry['content']}\n\n"
|
||||||
|
|
||||||
|
os.makedirs('chats', exist_ok=True)
|
||||||
|
with open(filename, 'w') as fp:
|
||||||
|
fp.write(markdown_content)
|
||||||
|
|
||||||
|
print(f"<<< Диалог сохранён в файл: {filename}\n")
|
||||||
|
self.query = None
|
||||||
|
|
||||||
|
def find_docs(self, query: str, top_k: int, collection_name: str):
|
||||||
|
self.print_v(text="\nПоиск документов...")
|
||||||
|
context_docs = self.rag.search_qdrant(query, top_k, collection_name)
|
||||||
|
self.print_v(text=f"Найдено {len(context_docs)} документов")
|
||||||
|
return context_docs
|
||||||
|
|
||||||
|
def rank_docs(self, docs: list = [], top_n = DEFAULT_TOP_N, min_score: int = DEFAULT_MIN_RANK_SCORE):
|
||||||
|
self.print_v(text="\nРанжирование документов...")
|
||||||
|
ranked_docs = self.rag.rank_documents(self.query, docs, top_n, min_score)
|
||||||
|
self.print_v(text=f"После ранжирования осталось {len(ranked_docs)} документов")
|
||||||
|
return ranked_docs
|
||||||
|
|
||||||
|
def prepare_ctx_sources(self, docs: list):
|
||||||
|
sources = ""
|
||||||
|
for idx, doc in enumerate(docs, start=1):
|
||||||
|
text = doc['payload'].get("text", "").strip()
|
||||||
|
sources = f"{sources}\n<source id=\"{idx}\">\n{text}\n</source>\n"
|
||||||
|
return sources
|
||||||
|
|
||||||
|
def prepare_cli_sources(self, docs: list):
|
||||||
|
sources = "\nИсточники:\n"
|
||||||
|
for idx, doc in enumerate(docs, start=1):
|
||||||
|
title = doc['payload'].get("filename", None)
|
||||||
|
url = doc['payload'].get("url", None)
|
||||||
|
date = doc['payload'].get("date", None)
|
||||||
|
version = doc['payload'].get("version", None)
|
||||||
|
author = doc['payload'].get("author", None)
|
||||||
|
|
||||||
|
if url is None:
|
||||||
|
url = "(нет веб-ссылки)"
|
||||||
|
if date is None:
|
||||||
|
date = "(неизвестно)"
|
||||||
|
if version is None:
|
||||||
|
version = "0"
|
||||||
|
if author is None:
|
||||||
|
author = "(неизвестен)"
|
||||||
|
|
||||||
|
sources += f"{idx}. {title}\n"
|
||||||
|
sources += f" {url}\n"
|
||||||
|
sources += f" Версия {version} от {author}, актуальная на {date}\n"
|
||||||
|
if doc['rank_score']:
|
||||||
|
sources += f" score = {doc['score']} | rank_score = {doc['rank_score']}\n"
|
||||||
|
else:
|
||||||
|
sources += f" score = {doc['score']}\n"
|
||||||
|
return sources
|
||||||
|
|
||||||
|
def prepare_sys_prompt(self, query: str, docs: list):
|
||||||
|
if self.is_custom_sys_prompt():
|
||||||
|
with open(self.args.sys_prompt, 'r') as fp:
|
||||||
|
prompt = fp.read()
|
||||||
|
else:
|
||||||
|
prompt = """You are a helpful assistant that can answer questions based on the provided context.
|
||||||
|
Your user is the person asking the source-related question.
|
||||||
|
Your job is to answer the question based on the context alone.
|
||||||
|
If the context doesn't provide much information, answer "I don't know."
|
||||||
|
Adhere to this in all languages.
|
||||||
|
|
||||||
|
Context:
|
||||||
|
|
||||||
|
-----------------------------------------
|
||||||
|
{{sources}}
|
||||||
|
-----------------------------------------
|
||||||
|
"""
|
||||||
|
|
||||||
|
sources = self.prepare_ctx_sources(docs)
|
||||||
|
return prompt.replace("{{sources}}", sources).replace("{{query}}", query)
|
||||||
|
|
||||||
|
def show_prompt(self, sys_prompt: str):
|
||||||
|
print("\n================ Системный промпт ==================")
|
||||||
|
print(f"{sys_prompt}\n============ Конец системного промпта ==============\n")
|
||||||
|
|
||||||
|
def process_query(self, sys_prompt: str, user_prompt: str, streaming: bool = DEFAULT_STREAM):
|
||||||
|
answer = ""
|
||||||
|
# try:
|
||||||
|
if streaming:
|
||||||
|
self.print_v(text="\nГенерация потокового ответа (^C для остановки)...\n")
|
||||||
|
print(f"<<< ", end='', flush=True)
|
||||||
|
for token in self.rag.generate_answer_stream(sys_prompt, user_prompt):
|
||||||
|
answer += token
|
||||||
|
print(token, end='', flush=True)
|
||||||
|
else:
|
||||||
|
self.print_v(text="\nГенерация ответа (^C для остановки)...\n")
|
||||||
|
answer = self.rag.generate_answer(sys_prompt, user_prompt)
|
||||||
|
print(f"<<< {answer}\n")
|
||||||
|
# except RuntimeError as e:
|
||||||
|
# answer = str(e)
|
||||||
|
|
||||||
|
print(f"\n===================================================")
|
||||||
|
return answer
|
||||||
|
|
||||||
|
def is_custom_sys_prompt(self):
|
||||||
|
return self.args.sys_prompt and os.path.exists(self.args.sys_prompt)
|
||||||
|
|
||||||
|
def print_stats(self):
|
||||||
|
print(f"* Time: {self.rag.get_total_duration()}s")
|
||||||
|
print(f"* TPS: {self.rag.get_tps()}")
|
||||||
|
print(f"* PEC: {self.rag.get_prompt_eval_count()}")
|
||||||
|
print(f"* PED: {self.rag.get_prompt_eval_duration()}s")
|
||||||
|
print(f"* EC: {self.rag.get_eval_count()}")
|
||||||
|
print(f"* ED: {self.rag.get_eval_duration()}s\n")
|
||||||
|
self.query = None
|
||||||
|
self.args.query = None
|
||||||
|
|
||||||
|
def process(self):
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
self.init_query()
|
||||||
|
|
||||||
|
if not self.query or self.query == "":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self.query.lower() == "help":
|
||||||
|
self.process_help()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self.query.strip().lower() == "save":
|
||||||
|
self.process_save()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self.query.strip().lower() == "stats":
|
||||||
|
print("\n<<< Статистика:")
|
||||||
|
self.print_stats()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self.query.strip().lower() == "exit":
|
||||||
|
self.print_v(text="\n*** Завершение работы")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
context_docs = self.find_docs(self.query, self.args.topk, self.args.qdrant_collection)
|
||||||
|
if not context_docs:
|
||||||
|
if args.interactive:
|
||||||
|
print("<<< Релевантные документы не найдены")
|
||||||
|
self.query = None
|
||||||
|
self.args.query = None
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
ranked_docs = self.rank_docs(context_docs, self.args.topn, self.args.min_rank_score)
|
||||||
|
if not ranked_docs:
|
||||||
|
if args.interactive:
|
||||||
|
print("<<< Документы были отсеяны полностью")
|
||||||
|
#TODO сделать ещё 2 попытки перезапроса+реранка других документов без учёта нерелевантных context_docs
|
||||||
|
self.query = None
|
||||||
|
self.args.query = None
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
# ctx = self.prepare_ctx_sources(ranked_docs)
|
||||||
|
sys_prompt = self.prepare_sys_prompt(self.query, ranked_docs)
|
||||||
|
if self.args.show_prompt:
|
||||||
|
self.show_prompt(sys_prompt)
|
||||||
|
|
||||||
|
# self.rag.conversation_history.append({
|
||||||
|
# "role": "system",
|
||||||
|
# "content": sys_prompt,
|
||||||
|
# })
|
||||||
|
|
||||||
|
self.rag.conversation_history.append({
|
||||||
|
"role": "system",
|
||||||
|
"content": sys_prompt,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.rag.conversation_history.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": self.query,
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
answer = self.process_query(sys_prompt, self.query, self.args.stream)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n*** Генерация ответа прервана")
|
||||||
|
self.query = None
|
||||||
|
self.args.query = None
|
||||||
|
print(self.prepare_cli_sources(ranked_docs))
|
||||||
|
if self.args.show_stats:
|
||||||
|
print("\nСтатистика:")
|
||||||
|
self.print_stats()
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(self.prepare_cli_sources(ranked_docs))
|
||||||
|
|
||||||
|
if self.args.show_stats:
|
||||||
|
print("\nСтатистика:")
|
||||||
|
self.print_stats()
|
||||||
|
|
||||||
|
self.rag.conversation_history.append({
|
||||||
|
"role": "assistant",
|
||||||
|
"docs": ranked_docs,
|
||||||
|
"content": answer,
|
||||||
|
})
|
||||||
|
|
||||||
|
if args.interactive:
|
||||||
|
self.query = None
|
||||||
|
self.args.query = None
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n*** Завершение работы")
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка: {e}")
|
||||||
|
break
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="RAG-система с использованием Ollama и Qdrant")
|
||||||
|
parser.add_argument("--query", type=str, help="Запрос к RAG")
|
||||||
|
parser.add_argument("--interactive", default=DEFAULT_INTERACTIVE, action=argparse.BooleanOptionalAction, help="Включить интерактивный режим диалога")
|
||||||
|
parser.add_argument("--stream", default=DEFAULT_STREAM, action=argparse.BooleanOptionalAction, help="Включить потоковый вывод")
|
||||||
|
parser.add_argument("--sys-prompt", type=str, help="Путь к файлу шаблона системного промпта")
|
||||||
|
parser.add_argument("--show-prompt", default=DEFAULT_SHOW_PROMPT, action=argparse.BooleanOptionalAction, help="Показать сист. промпт перед запросом")
|
||||||
|
parser.add_argument("--verbose", default=DEFAULT_VERBOSE, action=argparse.BooleanOptionalAction, help="Выводить служебные сообщения")
|
||||||
|
parser.add_argument("--show-stats", default=DEFAULT_SHOW_STATS, action=argparse.BooleanOptionalAction, help="Выводить статистику об ответе (не работает с --stream)")
|
||||||
|
parser.add_argument("--qdrant-host", default=DEFAULT_QDRANT_HOST, help="Адрес хоста Qdrant")
|
||||||
|
parser.add_argument("--qdrant-port", type=int, default=DEFAULT_QDRANT_PORT, help="Номер порта Qdrant")
|
||||||
|
parser.add_argument("--qdrant-collection", type=str, default=DEFAULT_QDRANT_COLLECTION, help="Название коллекции для поиска документов")
|
||||||
|
parser.add_argument("--ollama-url", default=DEFAULT_OLLAMA_URL, help="Ollama API URL")
|
||||||
|
parser.add_argument("--chat-model", default=DEFAULT_CHAT_MODEL, help="Модель генерации Ollama")
|
||||||
|
parser.add_argument("--emb-model", default=DEFAULT_EMBED_MODEL, help="Модель эмбеддинга")
|
||||||
|
parser.add_argument("--topk", type=int, default=DEFAULT_TOP_K, help="Количество документов для поиска")
|
||||||
|
parser.add_argument("--use-rank", default=DEFAULT_USE_RANK, action=argparse.BooleanOptionalAction, help="Включить ранжирование")
|
||||||
|
parser.add_argument("--rank-model", type=str, default=DEFAULT_RANK_MODEL, help="Модель ранжирования")
|
||||||
|
parser.add_argument("--min-rank-score", type=int, default=DEFAULT_MIN_RANK_SCORE, help="Минимальный ранк документа")
|
||||||
|
parser.add_argument("--topn", type=int, default=DEFAULT_TOP_N, help="Количество документов после ранжирования")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
app = App(args)
|
||||||
|
app.process()
|
||||||
132
rag/mindmap.puml
Normal file
132
rag/mindmap.puml
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
@startmindmap RAG
|
||||||
|
title Mindmap по построению RAG
|
||||||
|
header
|
||||||
|
https://git.axenov.dev/anthony/ollama
|
||||||
|
endheader
|
||||||
|
|
||||||
|
* RAG
|
||||||
|
|
||||||
|
** Подготовка сырых данных
|
||||||
|
*** Количество
|
||||||
|
****_ Больше => сложнее
|
||||||
|
*** Формат
|
||||||
|
**** HTML
|
||||||
|
*****_ Очистка
|
||||||
|
**** Markdown
|
||||||
|
**** JSON
|
||||||
|
**** PDF
|
||||||
|
***** OCR
|
||||||
|
****** Проблема плохого текста\n(сканы, картинки, фото)
|
||||||
|
****** Проблема разметки текста\n(колонки, обтекание картинок)
|
||||||
|
**** Проблема наличия таблиц
|
||||||
|
*****_ Оставить
|
||||||
|
*****_ Удалить
|
||||||
|
*****_ Конвертировать
|
||||||
|
******_ Markdown
|
||||||
|
*******_ Таблица
|
||||||
|
*******_ Список
|
||||||
|
******_ CSV
|
||||||
|
*** Качество
|
||||||
|
**** Очистка
|
||||||
|
*****_ Картинки
|
||||||
|
*****_ Бессмысленный текст
|
||||||
|
*****_ Разметка HTML, XML, ...
|
||||||
|
**** Метаданные
|
||||||
|
*****_ Название
|
||||||
|
*****_ Ссылка
|
||||||
|
*****_ Дата
|
||||||
|
*****_ Автор
|
||||||
|
*****_ ...
|
||||||
|
**** Семантика и смысл текстов
|
||||||
|
***** Разделение на осмысленные наборы
|
||||||
|
|
||||||
|
** Встраивание данных
|
||||||
|
*** Векторизация
|
||||||
|
**** Подбор модели эмбеддинга *
|
||||||
|
***** Проблема русского языка
|
||||||
|
**** Подбор способа разделения
|
||||||
|
***** Fixed-length chunking\n(строго по символам)
|
||||||
|
******_ самый простой и быстрый
|
||||||
|
******_ хорош для длинных текстов
|
||||||
|
******_ рвёт тексты с потерей связи
|
||||||
|
***** Semantic chunking\n(по смысловым блокам)
|
||||||
|
******_ посложнее
|
||||||
|
******_ сохраняет логику, даёт больше смысла
|
||||||
|
******_ лучше поиск
|
||||||
|
******_ чанки могут быть разных размеров
|
||||||
|
***** Structural chunking\n(по структуре текстов)
|
||||||
|
******_ самый сложный и медленный
|
||||||
|
******_ сохраняет контекст
|
||||||
|
******_ хорош для сложных и технических текстов
|
||||||
|
******_ требует структурированные входные данные
|
||||||
|
**** Подбор размера чанка
|
||||||
|
*****_ уменьшение
|
||||||
|
******_ меньше смысла
|
||||||
|
******_ больше нерелевантных результатов и галлюцинаций
|
||||||
|
******_ быстрее поиск
|
||||||
|
******_ хуже ответ
|
||||||
|
*****_ увеличение
|
||||||
|
******_ больше смысла
|
||||||
|
******_ больше размер контекста
|
||||||
|
******_ медленнее поиск
|
||||||
|
******_ хуже ответ
|
||||||
|
**** Подбор перекрытия чанков
|
||||||
|
*****_ уменьшение
|
||||||
|
******_ хуже смысловая связь документов
|
||||||
|
******_ более уникальные чанки
|
||||||
|
*****_ увеличение
|
||||||
|
******_ лучше смысловая связь документов
|
||||||
|
******_ сильнее дублирование частей чанков
|
||||||
|
*** Индексация
|
||||||
|
**** Метод хранения чанков
|
||||||
|
*****_ chromadb (примитивно на базе sqlite, но медленно)
|
||||||
|
*****_ qdrant (быстро, но немного усложняет деплой)
|
||||||
|
*****_ postgres + pgvector (сложнее)
|
||||||
|
*****_ ...
|
||||||
|
|
||||||
|
** Классификация\n(Classification)
|
||||||
|
*** Подбор модели классификации
|
||||||
|
**** Проблема русского языка
|
||||||
|
|
||||||
|
** Поиск и встраивание\n(Embedding)
|
||||||
|
***: Подбор модели эмбеддинга *
|
||||||
|
<i>та же, что на этапе векторизации</i>;
|
||||||
|
***: Подбор top_k
|
||||||
|
<i>количество чанков для поиска</i>;
|
||||||
|
|
||||||
|
** Ранжирование\n(Re-ranking)
|
||||||
|
*** Подбор модели реранкинга
|
||||||
|
' **** Проблема русского языка
|
||||||
|
***: Подбор top_n
|
||||||
|
<i>количество лучших чанков после реранка</i>;
|
||||||
|
|
||||||
|
** Генерация ответа
|
||||||
|
*** Подбор модели генерации
|
||||||
|
**** Проблема размера модели\n(млрд параметров)
|
||||||
|
*****_ меньше
|
||||||
|
******_ требует меньше ресурсов (RAM, CPU, GPU)
|
||||||
|
******_ выше скорость ответа (TPS)
|
||||||
|
******_ ниже качество (мешанина токенов, путает язык, игнорирует инструкции)
|
||||||
|
*****_ больше
|
||||||
|
******_ требует больше ресурсов (RAM, CPU, GPU)
|
||||||
|
******_ ниже скорость ответа (TPS)
|
||||||
|
******_ выше качество, но может быть избыточно, в зависимости от домена
|
||||||
|
*** Подбор системного промпта
|
||||||
|
**** Проблема русского языка
|
||||||
|
*****_ модель может его не понимать/генерировать
|
||||||
|
*****_ язык сложнее, занимает больше токенов в контексте
|
||||||
|
**** Проблема размера контекста
|
||||||
|
*****: Проблема Lost-in-the-middle
|
||||||
|
----
|
||||||
|
Исследование:
|
||||||
|
* коротко https://huggingface.co/papers/2307.03172
|
||||||
|
* целиком https://arxiv.org/abs/2307.03172
|
||||||
|
;
|
||||||
|
******_ ...
|
||||||
|
*****_ Сдвиг контекстного окна
|
||||||
|
*****_ Сжатие контекста
|
||||||
|
*** Подбор настроек генерации
|
||||||
|
****_ temperature
|
||||||
|
****_ top_p?
|
||||||
|
****_ ...
|
||||||
|
@endmindmap
|
||||||
0
rag/prompts/.gitkeep
Normal file
0
rag/prompts/.gitkeep
Normal file
192
rag/rag.py
192
rag/rag.py
@@ -1,16 +1,13 @@
|
|||||||
import os
|
import os
|
||||||
import requests
|
|
||||||
import json
|
|
||||||
import time
|
import time
|
||||||
import sys
|
import sys
|
||||||
from qdrant_client import QdrantClient
|
from qdrant_client import QdrantClient
|
||||||
from sentence_transformers import SentenceTransformer, CrossEncoder
|
from sentence_transformers import SentenceTransformer, CrossEncoder
|
||||||
|
import ollama
|
||||||
|
|
||||||
DEFAULT_CHAT_MODEL = "phi4-mini:3.8b"
|
DEFAULT_CHAT_MODEL = "openchat:7b"
|
||||||
DEFAULT_EMBED_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
|
DEFAULT_EMBED_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
|
||||||
DEFAULT_RANK_MODEL = "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1"
|
DEFAULT_RANK_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
||||||
# DEFAULT_RANK_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
|
||||||
# DEFAULT_RANK_MODEL = "cross-encoder/ms-marco-TinyBERT-L-2-v2"
|
|
||||||
DEFAULT_MD_FOLDER = "data"
|
DEFAULT_MD_FOLDER = "data"
|
||||||
DEFAULT_OLLAMA_URL = "http://localhost:11434"
|
DEFAULT_OLLAMA_URL = "http://localhost:11434"
|
||||||
DEFAULT_QDRANT_HOST = "localhost"
|
DEFAULT_QDRANT_HOST = "localhost"
|
||||||
@@ -24,6 +21,7 @@ DEFAULT_SHOW_STATS = False
|
|||||||
DEFAULT_STREAM = False
|
DEFAULT_STREAM = False
|
||||||
DEFAULT_INTERACTIVE = False
|
DEFAULT_INTERACTIVE = False
|
||||||
DEFAULT_SHOW_PROMPT = False
|
DEFAULT_SHOW_PROMPT = False
|
||||||
|
DEFAULT_MIN_RANK_SCORE = 0
|
||||||
|
|
||||||
class RagSystem:
|
class RagSystem:
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
@@ -39,18 +37,26 @@ class RagSystem:
|
|||||||
self.qdrant_port = qdrant_port
|
self.qdrant_port = qdrant_port
|
||||||
self.chat_model = chat_model
|
self.chat_model = chat_model
|
||||||
self.emb_model = SentenceTransformer(embed_model)
|
self.emb_model = SentenceTransformer(embed_model)
|
||||||
self.qdrant = QdrantClient(host=args.qdrant_host, port=args.qdrant_port)
|
self.qdrant = QdrantClient(host=qdrant_host, port=qdrant_port)
|
||||||
self.use_rank = use_rank
|
self.use_rank = use_rank
|
||||||
if self.use_rank:
|
if self.use_rank:
|
||||||
self.rank_model = CrossEncoder(rank_model)
|
self.rank_model = CrossEncoder(rank_model)
|
||||||
self.conversation_history = []
|
self.conversation_history = []
|
||||||
|
self.ollama = ollama.Client(base_url=ollama_url)
|
||||||
|
|
||||||
self.load_chat_model()
|
def check_chat_model(self):
|
||||||
|
models = self.ollama.list()
|
||||||
|
return any(model.name == self.chat_model for model in models)
|
||||||
|
|
||||||
|
def install_chat_model(self, model: str = DEFAULT_CHAT_MODEL):
|
||||||
|
try:
|
||||||
|
result = self.ollama.pull(model)
|
||||||
|
print(f"Модель {model} установлена успешно")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка установки модели: {str(e)}")
|
||||||
|
|
||||||
def load_chat_model(self):
|
def load_chat_model(self):
|
||||||
url = f"{self.ollama_url}/api/generate"
|
self.ollama.generate(model=self.chat_model, keep_alive=True)
|
||||||
body = {"model": self.chat_model}
|
|
||||||
requests.post(url, json=body, timeout=600)
|
|
||||||
|
|
||||||
def search_qdrant(self, query: str, doc_count: int = DEFAULT_TOP_K, collection_name = DEFAULT_QDRANT_COLLECTION):
|
def search_qdrant(self, query: str, doc_count: int = DEFAULT_TOP_K, collection_name = DEFAULT_QDRANT_COLLECTION):
|
||||||
query_vec = self.emb_model.encode(query, show_progress_bar=False).tolist()
|
query_vec = self.emb_model.encode(query, show_progress_bar=False).tolist()
|
||||||
@@ -68,100 +74,89 @@ class RagSystem:
|
|||||||
})
|
})
|
||||||
return docs
|
return docs
|
||||||
|
|
||||||
def rank_documents(self, query: str, documents: list, top_n: int = DEFAULT_TOP_N):
|
def rank_documents(self, query: str, documents: list, top_n: int = DEFAULT_TOP_N, min_score: int = DEFAULT_MIN_RANK_SCORE):
|
||||||
if not self.use_rank:
|
if not self.use_rank:
|
||||||
return documents
|
return documents
|
||||||
|
|
||||||
pairs = [[query, doc["payload"]["text"]] for doc in documents]
|
pairs = [[query, doc["payload"]["text"]] for doc in documents]
|
||||||
scores = self.rank_model.predict(pairs)
|
scores = self.rank_model.predict(pairs)
|
||||||
|
|
||||||
|
ranked_docs = []
|
||||||
for i, doc in enumerate(documents):
|
for i, doc in enumerate(documents):
|
||||||
doc["rank_score"] = float(scores[i])
|
score = float(scores[i])
|
||||||
|
doc["rank_score"] = score
|
||||||
|
if score >= min_score:
|
||||||
|
ranked_docs.append(doc)
|
||||||
|
|
||||||
documents.sort(key=lambda x: x['rank_score'], reverse=True)
|
ranked_docs.sort(key=lambda x: x['rank_score'], reverse=True)
|
||||||
return documents[:top_n]
|
return ranked_docs[:top_n]
|
||||||
|
|
||||||
def generate_answer(self, sys_prompt: str, user_prompt: str):
|
def generate_answer(self, sys_prompt: str, user_prompt: str):
|
||||||
url = f"{self.ollama_url}/api/generate"
|
try:
|
||||||
body = {
|
with self.ollama.generate(
|
||||||
"model": self.chat_model,
|
model=self.chat_model,
|
||||||
"system": sys_prompt,
|
prompt=sys_prompt + "\n" + user_prompt,
|
||||||
"prompt": user_prompt,
|
options={
|
||||||
#"context": self.conversation_history,
|
"temperature": 0.5,
|
||||||
"stream": False,
|
},
|
||||||
"options": {
|
stream=False,
|
||||||
"temperature": 0.5,
|
) as generator:
|
||||||
# "top_p": 0.2,
|
response = next(generator)
|
||||||
},
|
if response.error:
|
||||||
}
|
raise RuntimeError(f"Ошибка генерации: {response.error}")
|
||||||
|
self.last_response = response
|
||||||
response = requests.post(url, json=body, timeout=900)
|
return response.output
|
||||||
if response.status_code != 200:
|
except Exception as e:
|
||||||
return f"Ошибка генерации ответа: {response.status_code} {response.text}"
|
print(f"Ошибка генерации ответа: {str(e)}")
|
||||||
self.response = response.json()
|
return str(e)
|
||||||
return self.response["response"]
|
|
||||||
|
|
||||||
def generate_answer_stream(self, sys_prompt: str, user_prompt: str):
|
def generate_answer_stream(self, sys_prompt: str, user_prompt: str):
|
||||||
url = f"{self.ollama_url}/api/generate"
|
try:
|
||||||
body = {
|
generator = self.ollama.generate(
|
||||||
"model": self.chat_model,
|
model=self.chat_model,
|
||||||
"system": sys_prompt,
|
prompt=sys_prompt + "\n" + user_prompt,
|
||||||
"prompt": user_prompt,
|
options={
|
||||||
#"context": self.conversation_history,
|
"temperature": 0.5,
|
||||||
"stream": True,
|
},
|
||||||
"options": {
|
stream=True,
|
||||||
"temperature": 0.1,
|
)
|
||||||
"top_p": 0.2,
|
answer = ""
|
||||||
},
|
for response in generator:
|
||||||
}
|
if response.data:
|
||||||
resp = requests.post(url, json=body, stream=True, timeout=900)
|
yield response.data
|
||||||
if resp.status_code != 200:
|
answer += response.data
|
||||||
raise RuntimeError(f"Ошибка генерации ответа: {resp.status_code} {resp.text}")
|
if response.done:
|
||||||
|
self.last_response = response
|
||||||
answer = ""
|
break
|
||||||
for chunk in resp.iter_lines():
|
return answer
|
||||||
if chunk:
|
except Exception as e:
|
||||||
try:
|
print(f"Ошибка стриминга: {str(e)}")
|
||||||
decoded_chunk = chunk.decode('utf-8')
|
return str(e)
|
||||||
data = json.loads(decoded_chunk)
|
|
||||||
if "response" in data:
|
|
||||||
yield data["response"]
|
|
||||||
answer += data["response"]
|
|
||||||
if "done" in data and data["done"] is True:
|
|
||||||
self.response = data
|
|
||||||
break
|
|
||||||
elif "error" in data:
|
|
||||||
answer += f" | Ошибка стриминга ответа: {data['error']}"
|
|
||||||
break
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
answer += f" | Ошибка конвертации чанка: {chunk.decode('utf-8')} - {e}"
|
|
||||||
except Exception as e:
|
|
||||||
answer += f" | Ошибка обработки чанка: {e}"
|
|
||||||
|
|
||||||
def get_prompt_eval_count(self):
|
def get_prompt_eval_count(self):
|
||||||
if not self.response["prompt_eval_count"]:
|
if not hasattr(self, "last_response"):
|
||||||
return 0
|
return 0
|
||||||
return self.response["prompt_eval_count"]
|
return self.last_response.prompt_eval_count or 0
|
||||||
|
|
||||||
def get_prompt_eval_duration(self):
|
def get_prompt_eval_duration(self):
|
||||||
if not self.response["prompt_eval_duration"]:
|
if not hasattr(self, "last_response"):
|
||||||
return 0
|
return 0
|
||||||
return self.response["prompt_eval_duration"] / (10 ** 9)
|
return self.last_response.prompt_eval_duration / (10 ** 9)
|
||||||
|
|
||||||
def get_eval_count(self):
|
def get_eval_count(self):
|
||||||
if not self.response["eval_count"]:
|
if not hasattr(self, "last_response"):
|
||||||
return 0
|
return 0
|
||||||
return self.response["eval_count"]
|
return self.last_response.eval_count or 0
|
||||||
|
|
||||||
def get_eval_duration(self):
|
def get_eval_duration(self):
|
||||||
if not self.response["eval_duration"]:
|
if not hasattr(self, "last_response"):
|
||||||
return 0
|
return 0
|
||||||
return self.response["eval_duration"] / (10 ** 9)
|
return self.last_response.eval_duration / (10 ** 9)
|
||||||
|
|
||||||
def get_total_duration(self):
|
def get_total_duration(self):
|
||||||
if not self.response["total_duration"]:
|
if not hasattr(self, "last_response"):
|
||||||
return 0
|
return 0
|
||||||
return self.response["total_duration"] / (10 ** 9)
|
return self.last_response.total_duration / (10 ** 9)
|
||||||
|
|
||||||
def get_tps(self):
|
def get_tps(self):
|
||||||
eval_count = self.get_eval_count()
|
eval_count = self.get_eval_count()
|
||||||
@@ -216,6 +211,10 @@ class App:
|
|||||||
use_rank = self.args.use_rank,
|
use_rank = self.args.use_rank,
|
||||||
chat_model = self.args.chat_model
|
chat_model = self.args.chat_model
|
||||||
)
|
)
|
||||||
|
if not self.rag.check_chat_model():
|
||||||
|
print(f"Установка модели {self.args.chat_model} ...")
|
||||||
|
self.rag.install_chat_model(self.args.chat_model)
|
||||||
|
self.rag.load_chat_model()
|
||||||
self.print_v(text=f"Модели загружены. Если ответ плохой, переформулируйте запрос, укажите --chat-model или улучшите исходные данные RAG")
|
self.print_v(text=f"Модели загружены. Если ответ плохой, переформулируйте запрос, укажите --chat-model или улучшите исходные данные RAG")
|
||||||
|
|
||||||
def init_query(self):
|
def init_query(self):
|
||||||
@@ -231,8 +230,9 @@ class App:
|
|||||||
self.query = input(">>> ").strip()
|
self.query = input(">>> ").strip()
|
||||||
|
|
||||||
def process_help(self):
|
def process_help(self):
|
||||||
print("<<< Команды итерактивного режима:")
|
print("<<< Команды интерактивного режима:")
|
||||||
print("save -- сохранить диалог в файл")
|
print("save -- сохранить диалог в файл")
|
||||||
|
print("stats -- статистика последнего ответа")
|
||||||
print("exit -- выход\n")
|
print("exit -- выход\n")
|
||||||
self.query = None
|
self.query = None
|
||||||
self.args.query = None
|
self.args.query = None
|
||||||
@@ -271,9 +271,9 @@ class App:
|
|||||||
self.print_v(text=f"Найдено {len(context_docs)} документов")
|
self.print_v(text=f"Найдено {len(context_docs)} документов")
|
||||||
return context_docs
|
return context_docs
|
||||||
|
|
||||||
def rank_docs(self, docs: list = [], top_n = DEFAULT_TOP_N):
|
def rank_docs(self, docs: list = [], top_n = DEFAULT_TOP_N, min_score: int = DEFAULT_MIN_RANK_SCORE):
|
||||||
self.print_v(text="\nРанжирование документов...")
|
self.print_v(text="\nРанжирование документов...")
|
||||||
ranked_docs = self.rag.rank_documents(self.query, docs, top_n)
|
ranked_docs = self.rag.rank_documents(self.query, docs, top_n, min_score)
|
||||||
self.print_v(text=f"После ранжирования осталось {len(ranked_docs)} документов")
|
self.print_v(text=f"После ранжирования осталось {len(ranked_docs)} документов")
|
||||||
return ranked_docs
|
return ranked_docs
|
||||||
|
|
||||||
@@ -338,19 +338,23 @@ Context:
|
|||||||
|
|
||||||
def process_query(self, sys_prompt: str, user_prompt: str, streaming: bool = DEFAULT_STREAM):
|
def process_query(self, sys_prompt: str, user_prompt: str, streaming: bool = DEFAULT_STREAM):
|
||||||
answer = ""
|
answer = ""
|
||||||
# try:
|
|
||||||
if streaming:
|
if streaming:
|
||||||
self.print_v(text="\nГенерация потокового ответа (^C для остановки)...\n")
|
self.print_v(text="\nГенерация потокового ответа (^C для остановки)...\n")
|
||||||
print(f"<<< ", end='', flush=True)
|
print(f"<<< ", end='', flush=True)
|
||||||
for token in self.rag.generate_answer_stream(sys_prompt, user_prompt):
|
try:
|
||||||
answer += token
|
for token in self.rag.generate_answer_stream(sys_prompt, user_prompt):
|
||||||
print(token, end='', flush=True)
|
answer += token
|
||||||
|
print(token, end='', flush=True)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n*** Генерация ответа прервана")
|
||||||
|
return answer
|
||||||
else:
|
else:
|
||||||
self.print_v(text="\nГенерация ответа (^C для остановки)...\n")
|
self.print_v(text="\nГенерация ответа (^C для остановки)...\n")
|
||||||
answer = self.rag.generate_answer(sys_prompt, user_prompt)
|
try:
|
||||||
print(f"<<< {answer}\n")
|
answer = self.rag.generate_answer(sys_prompt, user_prompt)
|
||||||
# except RuntimeError as e:
|
except KeyboardInterrupt:
|
||||||
# answer = str(e)
|
print("\n*** Генерация ответа прервана")
|
||||||
|
return ""
|
||||||
|
|
||||||
print(f"\n===================================================")
|
print(f"\n===================================================")
|
||||||
return answer
|
return answer
|
||||||
@@ -403,10 +407,11 @@ Context:
|
|||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
|
||||||
ranked_docs = self.rank_docs(context_docs, self.args.topn)
|
ranked_docs = self.rank_docs(context_docs, self.args.topn, self.args.min_rank_score)
|
||||||
if not ranked_docs:
|
if not ranked_docs:
|
||||||
if args.interactive:
|
if args.interactive:
|
||||||
print("<<< Релевантные документы были отсеяны полностью")
|
print("<<< Документы были отсеяны полностью")
|
||||||
|
#TODO сделать ещё 2 попытки перезапроса+реранка других документов без учёта нерелевантных context_docs
|
||||||
self.query = None
|
self.query = None
|
||||||
self.args.query = None
|
self.args.query = None
|
||||||
continue
|
continue
|
||||||
@@ -456,10 +461,6 @@ Context:
|
|||||||
print("\n*** Завершение работы")
|
print("\n*** Завершение работы")
|
||||||
break
|
break
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Ошибка: {e}")
|
|
||||||
break
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
@@ -480,6 +481,7 @@ if __name__ == "__main__":
|
|||||||
parser.add_argument("--topk", type=int, default=DEFAULT_TOP_K, help="Количество документов для поиска")
|
parser.add_argument("--topk", type=int, default=DEFAULT_TOP_K, help="Количество документов для поиска")
|
||||||
parser.add_argument("--use-rank", default=DEFAULT_USE_RANK, action=argparse.BooleanOptionalAction, help="Включить ранжирование")
|
parser.add_argument("--use-rank", default=DEFAULT_USE_RANK, action=argparse.BooleanOptionalAction, help="Включить ранжирование")
|
||||||
parser.add_argument("--rank-model", type=str, default=DEFAULT_RANK_MODEL, help="Модель ранжирования")
|
parser.add_argument("--rank-model", type=str, default=DEFAULT_RANK_MODEL, help="Модель ранжирования")
|
||||||
|
parser.add_argument("--min-rank-score", type=int, default=DEFAULT_MIN_RANK_SCORE, help="Минимальный ранк документа")
|
||||||
parser.add_argument("--topn", type=int, default=DEFAULT_TOP_N, help="Количество документов после ранжирования")
|
parser.add_argument("--topn", type=int, default=DEFAULT_TOP_N, help="Количество документов после ранжирования")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user