From f3672e6ffd3e9caa7262c3226962d14badc182ff Mon Sep 17 00:00:00 2001 From: AnthonyAxenov Date: Fri, 29 Aug 2025 08:54:43 +0800 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D0=BA=D0=B8=D1=85=20=D0=B4=D0=BE=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - переименован input_md => data - добавление инфы о дате, версии и авторе изменений conf-страницы в индекс - вывод этой инфы в источниках - вывод статистики последнего ответа - указание имени коллекции для qdrant - мелочи по текстовкам --- .gitignore | 2 +- README.md | 2 +- rag/README.md | 18 +++++--- rag/clear.sh | 3 +- rag/convert.py | 4 +- rag/{input_md => data}/.gitkeep | 0 rag/download.sh | 27 +++++++++--- rag/rag.py | 54 +++++++++++++++++------ rag/vectorize.py | 78 +++++++++++++++++++++++---------- up | 1 + 10 files changed, 136 insertions(+), 53 deletions(-) rename rag/{input_md => data}/.gitkeep (100%) diff --git a/.gitignore b/.gitignore index 96b1cb8..def2f5e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ /.data/* /rag/input_html/* -/rag/input_md/* +/rag/data/* /rag/sys_prompt.txt /rag/chats/*.md diff --git a/README.md b/README.md index 97ed0b2..5d7b151 100644 --- a/README.md +++ b/README.md @@ -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) ## Как использовать diff --git a/rag/README.md b/rag/README.md index 03d78a2..1720a9a 100644 --- a/rag/README.md +++ b/rag/README.md @@ -37,7 +37,7 @@ RAG (Retrieval-Augmented Generation) — это архитектура, кото ``` rag/ ├── input_html/ # Входные файлы HTML, загруженные из Confluence -├── input_md/ # Входные (конвертированные) файлы Markdown +├── data/ # Входные (конвертированные) Markdown и прочие текстовые файлы ├── chats/ # Директория для сохранения чатов ├── download.sh # Скрипт для загрузки страниц из Confluence ├── convert.py # Скрипт конвертации HTML в Markdown @@ -121,7 +121,7 @@ https://conf.company.ltd/pages/viewpreviousversions.action?pageId=987654321 python3 convert.py ``` -В результате все html-файлы будут сохранены в директорию `./input_md/`. +В результате все html-файлы будут сохранены в директорию `./data/`. Файлы будут названы по заголовкам страниц, внутри также сохранится ссылка на исходную страницу `@@...@@`. Для получения справки по скрипту выполни команду: @@ -132,9 +132,9 @@ python3 convert.py --help ### 3. Векторизация (индексирование) -Файлы `./input_md/*.md` должны быть проиндексированы. +Файлы `./data/*` должны быть проиндексированы. -Для того, чтобы проиндексировать Markdown-документы, выполнить команду: +Для того, чтобы проиндексировать документы, выполнить команду: ```bash python3 vectorize.py @@ -185,6 +185,9 @@ python3 rag.py --query "твой запрос здесь" python3 rag.py --help ``` +> [!NOTE] +> У скрипта очень довольно аргументов для гибкой настройки. + ### Кастомный системный промпт Если хочется уточнить роль генеративной модели, можно создать файл `sys_prompt.txt` и прописать туда всё необходимое, учитывая следующие правила: @@ -213,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), тем меньше вероятности получить корректный ответ на не-английском языке. +> Такие модели работают быстро, но качество ответов низкое. +> Чем больше параметров, тем лучше и медленее ответы. ## Дисклеймер diff --git a/rag/clear.sh b/rag/clear.sh index 09cc91d..6cb7302 100755 --- a/rag/clear.sh +++ b/rag/clear.sh @@ -1,4 +1,5 @@ #!/bin/bash rm -rf ./input_html/*.html -rm -rf ./input_md/*.md +rm -rf ./data/* +touch ./data/.gitkeep diff --git a/rag/convert.py b/rag/convert.py index 99d4e3d..d84f2ad 100644 --- a/rag/convert.py +++ b/rag/convert.py @@ -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) diff --git a/rag/input_md/.gitkeep b/rag/data/.gitkeep similarity index 100% rename from rag/input_md/.gitkeep rename to rag/data/.gitkeep diff --git a/rag/download.sh b/rag/download.sh index 5d0fc3a..323d423 100755 --- a/rag/download.sh +++ b/rag/download.sh @@ -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
Исходная страница: $URL

$TITLE

$CONTENT" > "$HTML_FILEPATH" + + cat > "$HTML_FILEPATH" < + + @@ $URL @@ + ^^ $VERSION_NUM ^^ + %% $VERSION_BY %% + == $VERSION_WHEN == + +

$TITLE

+ $CONTENT + +EOF echo "Сохранено: $OUTPUT_PATH/$TITLE.html" CHILD_IDS=$(echo "$RESPONSE" | jq -r '.children.page.results[]?.id' 2>/dev/null) diff --git a/rag/rag.py b/rag/rag.py index d7c7898..81a7d39 100644 --- a/rag/rag.py +++ b/rag/rag.py @@ -6,7 +6,7 @@ from sentence_transformers import SentenceTransformer 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, @@ -27,13 +27,14 @@ class RagSystem: body = {"model": self.chat_model} requests.post(url, json=body, timeout=600) - def search_qdrant(self, query: str, top_k: int = 6): + 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/rag_collection/points/search" + 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: @@ -114,7 +115,7 @@ Respond.""" # "top_p": 0.1, # }, } - self.response = requests.post(url, json=body, timeout=600) + 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() @@ -127,7 +128,7 @@ Respond.""" "messages": self.conversation_history, "stream": True } - resp = requests.post(url, json=body, stream=True, timeout=600) + 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 = "" @@ -148,18 +149,28 @@ Respond.""" 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): @@ -172,19 +183,31 @@ Respond.""" 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}\n") + 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("Статистика:") + 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()}") @@ -202,6 +225,7 @@ 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="phi4-mini:3.8b", help="Модель генерации Ollama") @@ -220,6 +244,7 @@ def main(): 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_v("Будет использоваться sys_prompt.txt!", args.verbose) @@ -252,8 +277,9 @@ def main(): if query.lower() == "help": print("<<< Команды итерактивного режима:") - print("save -- сохранить диалог в файл") - print("exit -- выход\n") + print("save -- сохранить диалог в файл") + print("stats -- вывести статистику последнего ответа") + print("exit -- выход\n") query = None continue @@ -290,9 +316,9 @@ def main(): break print_v("\nПоиск релевантных документов...", args.verbose) - context_docs = rag.search_qdrant(query, top_k=args.topk) + context_docs = rag.search_qdrant(query, top_k=args.topk, qdrant_collection=args.qdrant_collection) if not context_docs: - print_v("Релевантные документы не найдены.", args.verbose) + print("<<< Релевантные документы не найдены") if args.interactive: query = None continue diff --git a/rag/vectorize.py b/rag/vectorize.py index 2996539..5102a0f 100644 --- a/rag/vectorize.py +++ b/rag/vectorize.py @@ -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) diff --git a/up b/up index 32e4dbd..cb2afd8 100755 --- a/up +++ b/up @@ -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"