1
0

Compare commits

...

10 Commits

10 changed files with 883 additions and 249 deletions

2
.gitignore vendored
View File

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

View File

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

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

View File

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

View File

@@ -1,17 +0,0 @@
# Чек-лист по построению RAG
* [ ] Определиться с форматом входных данных
* [ ] Очистить входные данные, обеспечив метаданными
* [ ] Подобрать модель эмбеддинга
* [ ] Подобрать размер чанка и перекрытия для эмбеддинга
* [ ] Подобрать место хранения (векторная СУБД)
* [ ] Подобрать модель ранжирования
* [ ] Подобрать модель генерации
* [ ] Подобрать для неё системный промпт (для встраивания найденных чанков, грамотного их цитирования)
* [ ] Подобрать параметры:
* [ ] top_k (количество чанков для поиска при эмбеддинге)
* [ ] top_n (остаток найденных чанков после ранжирования)
* [ ] temperature (степень фантазии)
* [ ] top_p (???)
* [ ] другие?
* [ ]

View File

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

View File

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