1
0

Много мелких доработок

- переименован input_md => data
- добавление инфы о дате, версии и авторе изменений conf-страницы в индекс
- вывод этой инфы в источниках
- вывод статистики последнего ответа
- указание имени коллекции для qdrant
- мелочи по текстовкам
This commit is contained in:
2025-08-29 08:54:43 +08:00
parent 3f2491db27
commit f3672e6ffd
10 changed files with 136 additions and 53 deletions

2
.gitignore vendored
View File

@@ -1,6 +1,6 @@
/.data/*
/rag/input_html/*
/rag/input_md/*
/rag/data/*
/rag/sys_prompt.txt
/rag/chats/*.md

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,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), тем меньше вероятности получить корректный ответ на не-английском языке.
> Такие модели работают быстро, но качество ответов низкое.
> Чем больше параметров, тем лучше и медленее ответы.
## Дисклеймер

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)

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

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

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"