1
0

Compare commits

...

2 Commits

Author SHA1 Message Date
f3672e6ffd Много мелких доработок
- переименован input_md => data
- добавление инфы о дате, версии и авторе изменений conf-страницы в индекс
- вывод этой инфы в источниках
- вывод статистики последнего ответа
- указание имени коллекции для qdrant
- мелочи по текстовкам
2025-08-29 08:54:43 +08:00
3f2491db27 WIP 2025-08-27 00:20:10 +08:00
18 changed files with 415 additions and 121 deletions

3
.gitignore vendored
View File

@@ -1,7 +1,8 @@
/.data/*
/rag/input_html/*
/rag/input_md/*
/rag/data/*
/rag/sys_prompt.txt
/rag/chats/*.md
.old/
.venv/

3
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

7
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.13" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13 (ollama)" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/ollama.iml" filepath="$PROJECT_DIR$/.idea/ollama.iml" />
</modules>
</component>
</project>

17
.idea/ollama.iml generated Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.13 (ollama)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="GOOGLE" />
<option name="myDocStringFormat" value="Google" />
</component>
<component name="TestRunnerService">
<option name="PROJECT_TEST_RUNNER" value="py.test" />
</component>
</module>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -31,7 +31,7 @@
* python, venv, pip
* [docker](https://docker.com)
* [ollama](https://ollama.com)
* [qdrant](https://qdrant.tech)
* [qdrant](https://qdrant.tech/documentation/quickstart/)
* [open-webui](https://docs.openwebui.com)
## Как использовать

View File

@@ -37,9 +37,10 @@ RAG (Retrieval-Augmented Generation) — это архитектура, кото
```
rag/
├── input_html/ # Входные файлы HTML, загруженные из Confluence
├── input_md/ # Входные (конвертированные) файлы Markdown
├── download.sh # Скрипт для загрузки страниц из Confluence
├── convert.py # Скрипт конвертации HTML в Markdown
├── data/ # Входные (конвертированные) Markdown и прочие текстовые файлы
├── chats/ # Директория для сохранения чатов
├── download.sh # Скрипт для загрузки страниц из Confluence
├── convert.py # Скрипт конвертации HTML в Markdown
├── vectorize.py # Скрипт векторизации Markdown
├── rag.py # Основной скрипт RAG системы
├── clear.sh # Скрипт очистки html/md файлов
@@ -120,7 +121,7 @@ https://conf.company.ltd/pages/viewpreviousversions.action?pageId=987654321
python3 convert.py
```
В результате все html-файлы будут сохранены в директорию `./input_md/`.
В результате все html-файлы будут сохранены в директорию `./data/`.
Файлы будут названы по заголовкам страниц, внутри также сохранится ссылка на исходную страницу `@@...@@`.
Для получения справки по скрипту выполни команду:
@@ -131,9 +132,9 @@ python3 convert.py --help
### 3. Векторизация (индексирование)
Файлы `./input_md/*.md` должны быть проиндексированы.
Файлы `./data/*` должны быть проиндексированы.
Для того, чтобы проиндексировать Markdown-документы, выполнить команду:
Для того, чтобы проиндексировать документы, выполнить команду:
```bash
python3 vectorize.py
@@ -184,6 +185,9 @@ python3 rag.py --query "твой запрос здесь"
python3 rag.py --help
```
> [!NOTE]
> У скрипта очень довольно аргументов для гибкой настройки.
### Кастомный системный промпт
Если хочется уточнить роль генеративной модели, можно создать файл `sys_prompt.txt` и прописать туда всё необходимое, учитывая следующие правила:
@@ -212,10 +216,15 @@ python3 rag.py --help
Для генерации ответов:
- `qwen2.5:3b` (по умолчанию)
- `qwen3:8b`
- `gemma3n:e2b`
- `phi4-mini:3.8b`
- `qwen2.5:1.5b`
- ...
> [!NOTE]
> Чем меньше млрд параметров (b, billion), тем меньше вероятности получить корректный ответ на не-английском языке.
> Такие модели работают быстро, но качество ответов низкое.
> Чем больше параметров, тем лучше и медленее ответы.
## Дисклеймер

View File

@@ -3,6 +3,7 @@
* [ ] Описать подготовку знаний в Open WebUI
* [ ] Обработка pdf, json, ...
* [ ] Ранжировние результатов
* [ ] Конвертирование таблиц в списки
* [ ] Режим диалога (запоминание запросов и ответов)
* [ ] API
* [ ] Telegram-бот

View File

@@ -1,4 +1,5 @@
#!/bin/bash
rm -rf ./input_html/*.html
rm -rf ./input_md/*.md
rm -rf ./data/*
touch ./data/.gitkeep

View File

@@ -15,11 +15,11 @@ def convert_html_to_md(input_dir, output_dir):
md_content = markdownify.markdownify(html_content, heading_style="ATX")
with open(output_path, "w", encoding="utf-8") as f:
f.write(md_content)
print(f"Converted {input_path} to {output_path}")
print(f"Готово: {output_path}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Конвертер HTML-файлов в Markdown")
parser.add_argument("--input-dir", type=str, default="input_html", help="Директория с HTML-файлами для конвертации")
parser.add_argument("--output-dir", type=str, default="input_md", help="Директория для сохранения Markdown-файлов")
parser.add_argument("--output-dir", type=str, default="data", help="Директория для сохранения Markdown-файлов")
args = parser.parse_args()
convert_html_to_md(args.input_dir, args.output_dir)

0
rag/data/.gitkeep Normal file
View File

View File

@@ -21,7 +21,7 @@ OUTPUT_PATH="./input_html"
[ ! -d "$OUTPUT_PATH" ] && mkdir -p "$OUTPUT_PATH"
for PAGE_ID in "$@"; do
API_ENDPOINT="${CONF_URL}/rest/api/content/${PAGE_ID}?expand=body.view,children.page"
API_ENDPOINT="${CONF_URL}/rest/api/content/${PAGE_ID}?expand=body.view,children.page,version"
echo
echo "Загрузка: $API_ENDPOINT"
@@ -37,25 +37,40 @@ for PAGE_ID in "$@"; do
exit 1
fi
TITLE=$(echo "$RESPONSE" | jq -r .title)
CONTENT=$(echo "$RESPONSE" | jq -r .body.view.value)
if [ -z "$CONTENT" ]; then
echo "Пустая страница, пропущено"
exit
fi
TITLE=$(echo "$RESPONSE" | jq -r .title)
VERSION_NUM=$(echo "$RESPONSE" | jq -r .version.number)
VERSION_WHEN=$(date -d "$(echo "$RESPONSE" | jq -r .version.when)" +'%d.%m.%Y %H:%M:%S %Z')
VERSION_BY=$(echo "$RESPONSE" | jq -r .version.by.username)
TITLE_ESC="${TITLE//\//_}"
FILENAME="$TITLE_ESC.html"
if [ "$(echo "$FILENAME" | wc -c)" -gt 255 ]; then # измерение по байтам, а не длине
FILENAME="${TITLE_ESC:0:120}.html"
fi
HTML_FILEPATH="$OUTPUT_PATH/$FILENAME"
CONTENT=${CONTENT//href=\"\//href=\"$CONF_URL}
CONTENT=${CONTENT//src=\"\//src=\"$CONF_URL}
CONTENT=${CONTENT//href=\"\//href=\"$CONF_URL/}
CONTENT=${CONTENT//src=\"\//src=\"$CONF_URL/}
URL="$CONF_URL/pages/viewpage.action?pageId=$PAGE_ID"
echo -e "@@$URL@@\n<br><html><body>Исходная страница: <a href=$URL>$URL</a><br><br><h1>$TITLE</h1>$CONTENT</body></html>" > "$HTML_FILEPATH"
cat > "$HTML_FILEPATH" <<EOF
<html><body>
<!-- разметка для эмбеддинга -->
@@ $URL @@
^^ $VERSION_NUM ^^
%% $VERSION_BY %%
== $VERSION_WHEN ==
<!-- / разметка для эмбеддинга -->
<h1>$TITLE</h1>
$CONTENT
</body></html>
EOF
echo "Сохранено: $OUTPUT_PATH/$TITLE.html"
CHILD_IDS=$(echo "$RESPONSE" | jq -r '.children.page.results[]?.id' 2>/dev/null)

View File

@@ -1,36 +1,40 @@
import argparse
import os
import hashlib
import requests
import json
import time
from sentence_transformers import SentenceTransformer
class LocalRAGSystem:
class RagSystem:
def __init__(self,
md_folder: str = "input_md",
md_folder: str = "data",
ollama_url: str = "http://localhost:11434",
qdrant_host: str = "localhost",
qdrant_port: int = 6333,
embed_model: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
chat_model: str = "qwen2.5:3b"):
chat_model: str = "phi4-mini:3.8b"):
self.md_folder = md_folder
self.ollama_url = ollama_url
self.qdrant_host = qdrant_host
self.qdrant_port = qdrant_port
self.embed_model = embed_model
self.chat_model = chat_model
self.emb_model = SentenceTransformer(embed_model)
self.prompt = ""
self.conversation_history = []
self.load_chat_model()
def get_embedding(self, text: str):
return self.emb_model.encode(text, show_progress_bar=False).tolist()
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, top_k: int = 6):
query_vec = self.get_embedding(query)
url = f"http://{self.qdrant_host}:{self.qdrant_port}/collections/rag_collection/points/search"
def search_qdrant(self, query: str, top_k: int = 6, qdrant_collection="rag"):
query_vec = self.emb_model.encode(query, show_progress_bar=False).tolist()
url = f"http://{self.qdrant_host}:{self.qdrant_port}/collections/{qdrant_collection}/points/search"
payload = {
"vector": query_vec,
"top": top_k,
"with_payload": True
"with_payload": True,
# "score_threshold": 0.6
}
resp = requests.post(url, json=payload)
if resp.status_code != 200:
@@ -38,69 +42,178 @@ class LocalRAGSystem:
results = resp.json().get("result", [])
return results
def generate_answer(self, prompt: str):
url = f"{self.ollama_url}/api/generate"
body = {
"model": self.chat_model,
"prompt": prompt,
"stream": False
}
resp = requests.post(url, json=body, timeout=600)
if resp.status_code != 200:
return f"Ошибка генерации ответа: {resp.status_code} {resp.text}"
return resp.json().get("response", "").strip()
def prepare_sources(self, context_docs: list):
sources = ""
for idx, doc in enumerate(context_docs, start=1):
text = doc['payload'].get("text", "").strip()
sources = f"{sources}\n--- Source [{idx}] ---\n{text}\n"
return sources.strip()
sources = f"{sources}\n<source id=\"{idx}\">\n{text}\n</source>\n"
return sources
def prepare_prompt(self, query: str, context_docs: list):
sources = self.prepare_sources(context_docs)
if os.path.exists('sys_prompt.txt'):
with open('sys_prompt.txt', 'r') as fp:
return fp.read().replace("{{sources}}", sources).replace("{{query}}", query)
prompt_template = fp.read()
return prompt_template.replace("{{sources}}", sources).replace("{{query}}", query)
else:
return f"""
Please provide an answer based solely on the provided sources.
It is prohibited to generate an answer based on your pretrained data.
If uncertain, ask the user for clarification.
Respond in the same language as the user's query.
If there are no sources in context, clearly state that.
If the context is unreadable or of poor quality, inform the user and provide the best possible answer.
When referencing information from a source, cite the appropriate source(s) using their corresponding numbers.
Every answer should include at least one source citation.
Only cite a source when you are explicitly referencing it.
return f"""### Your role
You are a helpful assistant that can answer questions based on the provided sources.
If none of the sources are helpful, you should indicate that.
For example:
### Your user
User is a human who is asking a question related to the provided sources.
--- Source 1 ---
The sky is red in the evening and blue in the morning.
### Your task
Please provide an answer based solely on the provided sources and the conversation history.
--- Source 2 ---
Water is wet when the sky is red.
### Rules
- You **MUST** respond in the SAME language as the user's query.
- If uncertain, you **MUST** the user for clarification.
- If there are no sources in context, you **MUST** clearly state that.
- If none of the sources are helpful, you **MUST** clearly state that.
- If you are unsure about the answer, you **MUST** clearly state that.
- If the context is unreadable or of poor quality, you **MUST** inform the user and provide the best possible answer.
- When referencing information from a source, you **MUST** cite the appropriate source(s) using their corresponding numbers.
- **Only include inline citations using [id] (e.g., [1], [2]) when the <source> tag includes an id attribute.**
- You NEVER MUST NOT add <source> or any XML/HTML tags in your response.
- You NEVER MUST NOT cite if the <source> tag does not contain an id attribute.
- Every answer MAY include at least one source citation.
- Only cite a source when you are explicitly referencing it.
- You may also cite multiple sources if they are all relevant to the question.
- Ensure citations are concise and directly related to the information provided.
- You CAN format your responses using Markdown.
Query: When is water wet?
Answer: Water will be wet when the sky is red [2], which occurs in the evening [1].
### Example of sources list:
Now it's your turn. Below are several numbered sources of information:
{context}
```
<source id="1">The sky is red in the evening and blue in the morning.</source>
<source id="2">Water is wet when the sky is red.</source>
<query>When is water wet?</query>
```
Response:
```
Water will be wet when the sky is red [2], which occurs in the evening [1].
```
User query: {query}
Your answer:
"""
### Now let's start!
```
{sources}
<query>{query}</query>
```
Respond."""
def generate_answer(self, prompt: str):
url = f"{self.ollama_url}/api/generate"
body = {
"model": self.chat_model,
"prompt": prompt,
"messages": self.conversation_history,
"stream": False,
# "options": {
# "temperature": 0.4,
# "top_p": 0.1,
# },
}
self.response = requests.post(url, json=body, timeout=900)
if self.response.status_code != 200:
return f"Ошибка генерации ответа: {self.response.status_code} {self.response.text}"
return self.response.json().get("response", "").strip()
def generate_answer_stream(self, prompt: str):
url = f"{self.ollama_url}/api/generate"
body = {
"model": self.chat_model,
"prompt": prompt,
"messages": self.conversation_history,
"stream": True
}
resp = requests.post(url, json=body, stream=True, timeout=900)
if resp.status_code != 200:
raise RuntimeError(f"Ошибка генерации ответа: {resp.status_code} {resp.text}")
full_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"]
full_answer += data["response"]
elif "error" in data:
print(f"Stream error: {data['error']}")
break
except json.JSONDecodeError:
print(f"Could not decode JSON from chunk: {chunk.decode('utf-8')}")
except Exception as e:
print(f"Error processing chunk: {e}")
def get_prompt_eval_count(self):
if not self.response:
return 0
return self.response.json().get("prompt_eval_count", 0)
def get_prompt_eval_duration(self):
if not self.response:
return 0
return self.response.json().get("prompt_eval_duration", 0) / (10 ** 9)
def get_eval_count(self):
if not self.response:
return 0
return self.response.json().get("eval_count", 0)
def get_eval_duration(self):
if not self.response:
return 0
return self.response.json().get("eval_duration", 0) / (10 ** 9)
def get_total_duration(self):
if not self.response:
return 0
return self.response.json().get("total_duration", 0) / (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
def print_sources(context_docs: list):
print("\n\nИсточники:")
for idx, doc in enumerate(context_docs, start=1):
filename = doc['payload'].get("filename", None)
title = doc['payload'].get("filename", None)
url = doc['payload'].get("url", None)
title = filename
date = doc['payload'].get("date", None)
version = doc['payload'].get("version", None)
author = doc['payload'].get("author", None)
if url is None:
url = "(нет веб-ссылки)"
print(f"{idx}. {title}\n {url}")
if date is None:
date = "(неизвестно)"
if version is None:
version = "0"
if author is None:
author = "(неизвестен)"
print(f"{idx}. {title}")
print(f" {url} (v{version} {author})")
print(f" актуальность на {date}")
def print_v(text: str, is_verbose: bool):
if is_verbose:
print(text)
def print_stats(rag: RagSystem):
print("\n\nСтатистика:")
print(f"* Time: {rag.get_total_duration()}s")
print(f"* TPS: {rag.get_tps()}")
print(f"* PEC: {rag.get_prompt_eval_count()}")
print(f"* PED: {rag.get_prompt_eval_duration()}s")
print(f"* EC: {rag.get_eval_count()}")
print(f"* ED: {rag.get_eval_duration()}s\n")
def main():
import sys
@@ -112,43 +225,47 @@ def main():
parser.add_argument("--show-prompt", default=False, action=argparse.BooleanOptionalAction, help="Показать полный промпт перед обработкой запроса")
parser.add_argument("--qdrant-host", default="localhost", help="Qdrant host")
parser.add_argument("--qdrant-port", type=int, default=6333, help="Qdrant port")
parser.add_argument("--qdrant-collection", type=str, default="rag", help="Название коллекции для поиска документов")
parser.add_argument("--ollama-url", default="http://localhost:11434", help="Ollama API URL")
parser.add_argument("--emb-model", default="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2", help="Модель эмбеддинга")
parser.add_argument("--chat-model", default="qwen2.5:3b", help="Модель генерации Ollama")
parser.add_argument("--chat-model", default="phi4-mini:3.8b", help="Модель генерации Ollama")
parser.add_argument("--topk", type=int, default=6, help="Количество документов для поиска")
parser.add_argument("--verbose", default=False, action=argparse.BooleanOptionalAction, help="Выводить промежуточные служебные сообщения")
parser.add_argument("--show-stats", default=False, action=argparse.BooleanOptionalAction, help="Выводить статистику об ответе (не работает с --stream)")
parser.add_argument("--stream", default=False, action=argparse.BooleanOptionalAction, help="Выводить статистику об ответе")
args = parser.parse_args()
if not args.query and not args.interactive:
print("Ошибка: укажите запрос (--query) и/или используйте интерактивный режим (--interactive)")
sys.exit(1)
print(f"Адрес ollama: {args.ollama_url}")
print(f"Адрес qdrant: {args.qdrant_host}:{args.qdrant_port}")
print(f"Модель эмбеддинга: {args.emb_model}")
print(f"Модель чата: {args.chat_model}")
print(f"Документов для поиска: {args.topk}")
print_v(f"Адрес ollama: {args.ollama_url}", args.verbose)
print_v(f"Адрес qdrant: {args.qdrant_host}:{args.qdrant_port}", args.verbose)
print_v(f"Модель эмбеддинга: {args.emb_model}", args.verbose)
print_v(f"Модель чата: {args.chat_model}", args.verbose)
print_v(f"Документов для поиска: {args.topk}", args.verbose)
print_v(f"Коллекция для поиска: {args.qdrant_collection}", args.verbose)
if os.path.exists('sys_prompt.txt'):
print("Будет использоваться sys_prompt.txt!")
print_v("Будет использоваться sys_prompt.txt!", args.verbose)
print("\nПервая инициализация моделей...")
rag = LocalRAGSystem(
print_v("\nПервая инициализация моделей...", args.verbose)
rag = RagSystem(
ollama_url=args.ollama_url,
qdrant_host=args.qdrant_host,
qdrant_port=args.qdrant_port,
embed_model=args.emb_model,
chat_model=args.chat_model
)
print(f"Модели загружены. Если ответ плохой, переформулируйте запрос, укажите --chat-model или улучшите исходные данные RAG")
print_v(f"Модели загружены. Если ответ плохой, переформулируйте запрос, укажите --chat-model или улучшите исходные данные RAG", args.verbose)
query = None
if args.interactive:
print("\nИНТЕРАКТИВНЫЙ РЕЖИМ")
print("Можете вводить запрос (или 'exit' для выхода)\n")
print_v("\nИНТЕРАКТИВНЫЙ РЕЖИМ", args.verbose)
print_v("Можете вводить запрос (или 'exit' для выхода)\n", args.verbose)
if args.query:
query = args.query.strip()
print(f">>> {query}")
else:
query = input(">>> ").strip()
while True:
try:
@@ -158,34 +275,104 @@ def main():
if not query or query == "":
continue
if query.lower() == "exit":
print("\n*** Завершение работы")
if query.lower() == "help":
print("<<< Команды итерактивного режима:")
print("save -- сохранить диалог в файл")
print("stats -- вывести статистику последнего ответа")
print("exit -- выход\n")
query = None
continue
if query.strip().lower() == "save":
import datetime
timestamp = int(time.time())
dt = datetime.datetime.fromtimestamp(timestamp).strftime('%Y-%m-%dT%H:%M:%SZ')
filename = f"chats/chat-{timestamp}.md"
markdown_content = f"# История диалога от {dt}\n\n"
markdown_content += f"## Параметры диалога\n"
markdown_content += f"```\nargs = {args}\n```\n"
markdown_content += f"```\nemb_model = {rag.emb_model}\n```\n"
for entry in rag.conversation_history:
if entry['role'] == 'user':
markdown_content += f"## Пользователь\n\n"
elif entry['role'] == 'assistant':
markdown_content += f"## Модель\n\n"
docs = rag.prepare_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")
query = None
continue
if query.strip().lower() == "exit":
print_v("\n*** Завершение работы", args.verbose)
break
print("\nПоиск релевантных документов...")
context_docs = rag.search_qdrant(query, top_k=args.topk)
print_v("\nПоиск релевантных документов...", args.verbose)
context_docs = rag.search_qdrant(query, top_k=args.topk, qdrant_collection=args.qdrant_collection)
if not context_docs:
print("Релевантные документы не найдены.")
print("<<< Релевантные документы не найдены")
if args.interactive:
query = None
continue
else:
break
print(f"Найдено {len(context_docs)} релевантных документов:")
print_sources(context_docs)
print_v(f"Найдено {len(context_docs)} релевантных документов", args.verbose)
# print_sources(context_docs)
prompt = rag.prepare_prompt(query=query, context_docs=context_docs)
if args.show_prompt:
print("\nПолный системный промпт: --------------------------\n")
print(f"{prompt}\n---------------------------------------------------\n")
print("\nПолный системный промпт: --------------------------")
print(f"{prompt}\n---------------------------------------------------")
print_v("\nГенерация ответа...\n", args.verbose)
if args.stream:
answer = "\n<<< "
print(answer, end='', flush=True)
try:
for message_part in rag.generate_answer_stream(prompt):
answer += message_part
print(message_part, end='', flush=True)
except RuntimeError as e:
answer = str(e)
print(f"\n{answer}\n===================================================\n")
else:
answer = rag.generate_answer(prompt)
print(f"<<< {answer}\n")
print_sources(context_docs)
if args.show_stats and not args.stream:
print_stats(rag)
rag.conversation_history.append({
"role": "user",
"content": query,
})
rag.conversation_history.append({
"role": "assistant",
"docs": context_docs,
"content": answer,
})
if args.interactive:
query = None
else:
break
print("Генерация ответа...")
answer = rag.generate_answer(prompt)
print(f"\n<<< {answer}\n===================================================\n")
query = None
except KeyboardInterrupt:
print("\n*** Завершение работы")
break
except Exception as e:
print(f"Ошибка: {e}")
break

View File

@@ -15,12 +15,39 @@ def load_markdown_files(input_dir):
content = f.read()
lines = content.splitlines()
url = None
version = None
author = None
date = None
if lines:
first_line = lines[0].strip()
if first_line.startswith("@@") and first_line.endswith("@@") and len(first_line) > 4:
url = first_line[2:-2].strip()
content = "\n".join(lines[1:]) # Remove the first line from content
documents.append({"id": filename, "text": content, "url": url})
# Проверка первой строки на URL
if lines[0].strip().startswith("@@") and lines[0].strip().endswith("@@") and len(lines[0].strip()) > 4:
url = lines[0].strip()[2:-2].strip()
lines = lines[1:]
# Проверка оставшихся строк на метаданные
i = 0
while i < len(lines):
line = lines[i].strip()
if line.startswith("^^") and line.endswith("^^") and len(line) > 4:
version = line[2:-2].strip()
lines.pop(i)
elif line.startswith("%%") and line.endswith("%%") and len(line) > 4:
author = line[2:-2].strip()
lines.pop(i)
elif line.startswith("==") and line.endswith("==") and len(line) > 4:
date = line[2:-2].strip()
lines.pop(i)
else:
i += 1
doc_metadata = {"id": filename, "text": "\n".join(lines)}
if url: doc_metadata["url"] = url
if version: doc_metadata["version"] = version
if author: doc_metadata["author"] = author
if date: doc_metadata["date"] = date
documents.append(doc_metadata)
return documents
def chunk_text(texts, chunk_size, chunk_overlap):
@@ -36,12 +63,15 @@ def chunk_text(texts, chunk_size, chunk_overlap):
for i, chunk in enumerate(doc_chunks):
chunk_id = f"{doc['id']}_chunk{i}"
chunk_dict = {"id": chunk_id, "text": chunk}
if "url" in doc and doc["url"] is not None:
chunk_dict["url"] = doc["url"]
# Перенос всех доступных метаданных
for key in ["url", "version", "author", "date"]:
if key in doc and doc[key] is not None:
chunk_dict[key] = doc[key]
chunks.append(chunk_dict)
return chunks
def embed_and_upload(chunks, embedding_model_name, qdrant_host="localhost", qdrant_port=6333):
def embed_and_upload(chunks, embedding_model_name, qdrant_host="localhost", qdrant_port=6333, qdrant_collection="rag"):
import hashlib
print(f"Инициализация модели {args.embedding_model}")
@@ -49,13 +79,12 @@ def embed_and_upload(chunks, embedding_model_name, qdrant_host="localhost", qdra
print(f"Подключение к qdrant ({qdrant_host}:{qdrant_port})")
client = QdrantClient(host=qdrant_host, port=qdrant_port)
collection_name = "rag_collection"
if client.collection_exists(collection_name):
client.delete_collection(collection_name)
if client.collection_exists(qdrant_collection):
client.delete_collection(qdrant_collection)
client.create_collection(
collection_name=collection_name,
collection_name=qdrant_collection,
vectors_config=models.VectorParams(size=embedder.get_sentence_embedding_dimension(), distance=models.Distance.COSINE),
)
@@ -72,7 +101,10 @@ def embed_and_upload(chunks, embedding_model_name, qdrant_host="localhost", qdra
payload={
"text": chunk["text"],
"filename": chunk["id"].rsplit(".md_chunk", 1)[0],
"url": chunk.get("url", None)
"url": chunk.get("url", None),
"version": chunk.get("version", None),
"author": chunk.get("author", None),
"date": chunk.get("date", None)
}
))
print(f"[{idx}/{total_chunks}] Подготовлен чанк: {chunk['id']} -> ID: {id_hash}")
@@ -80,20 +112,20 @@ def embed_and_upload(chunks, embedding_model_name, qdrant_host="localhost", qdra
batch_size = 100
for i in range(0, total_chunks, batch_size):
batch = points[i : i + batch_size]
client.upsert(collection_name=collection_name, points=batch)
client.upsert(collection_name=qdrant_collection, points=batch)
print(f"Записан батч {(i // batch_size) + 1}, содержащий {len(batch)} точек, всего записано: {min(i + batch_size, total_chunks)}/{total_chunks}")
print(f"Завершена запись всех {total_chunks} чанков в коллекцию '{collection_name}'.")
print(f"Завершена запись всех {total_chunks} чанков в коллекцию '{qdrant_collection}'.")
if __name__ == "__main__":
print(f"Инициализация...")
parser = argparse.ArgumentParser(description="Скрипт векторизаци данных для Qdrant")
parser.add_argument("--input_dir", type=str, default="input_md", help="Директория с Markdown-файлами для чтения")
parser.add_argument("--chunk_size", type=int, default=500, help="Размер чанка")
parser.add_argument("--chunk_overlap", type=int, default=100, help="Размер перекрытия")
parser.add_argument("--embedding_model", type=str, default="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2", help="Модель эмбеддинга")
parser.add_argument("--qdrant_host", type=str, default="localhost", help="Адрес хоста Qdrant")
parser.add_argument("--qdrant_port", type=int, default=6333, help="Номер порта Qdrant")
parser.add_argument("--input-dir", type=str, default="data", help="Директория с Markdown-файлами для чтения")
parser.add_argument("--chunk-size", type=int, default=500, help="Размер чанка")
parser.add_argument("--chunk-overlap", type=int, default=100, help="Размер перекрытия")
parser.add_argument("--embedding-model", type=str, default="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2", help="Модель эмбеддинга")
parser.add_argument("--qdrant-host", type=str, default="localhost", help="Адрес хоста Qdrant")
parser.add_argument("--qdrant-port", type=int, default=6333, help="Номер порта Qdrant")
parser.add_argument("--qdrant-collection", type=str, default="rag", help="Название коллекции для сохранения документов")
args = parser.parse_args()
documents = load_markdown_files(args.input_dir)
@@ -103,4 +135,4 @@ if __name__ == "__main__":
chunks = chunk_text(documents, args.chunk_size, args.chunk_overlap)
print(f"Создано чанков: {len(chunks)} ({args.chunk_size}/{args.chunk_overlap})")
embed_and_upload(chunks, args.embedding_model, args.qdrant_host, args.qdrant_port)
embed_and_upload(chunks, args.embedding_model, args.qdrant_host, args.qdrant_port, args.qdrant_collection)

1
up
View File

@@ -7,4 +7,5 @@ docker compose up -d --build --remove-orphans
echo "* Ollama доступен по адресу: localhost:$OLLAMA_PORT"
echo "* Open WebUI доступен по адресу: http://localhost:$QDRANT_PORT/"
echo "* Qdrant доступен по адресу: localhost:$OWEBUI_PORT"
echo "* Qdrant UI доступен по адресу: http://localhost:$OWEBUI_PORT/dashboard"
echo "Для остановки контейнеров выполните ./down"