diff --git a/README.md b/README.md index f41ce48..acaabc4 100644 --- a/README.md +++ b/README.md @@ -352,3 +352,8 @@ chmod +x script.sh 4. Запуск минимального RAG с Qdrant 5. Добавление MCP-инструментов под ваши рабочие данные 6. Оптимизация скорости и стоимости (routing между моделями) + +## Дополнительные материалы в репозитории + +- Быстрый гайд: `docs/quickstart-30min.md` +- Готовый starter: `docs/rag-mcp-starter/` diff --git a/docs/quickstart-30min.md b/docs/quickstart-30min.md new file mode 100644 index 0000000..07cd895 --- /dev/null +++ b/docs/quickstart-30min.md @@ -0,0 +1,103 @@ +# OpenClaude + Local LLM: Quickstart 30 min + +Короткий маршрут "с нуля до рабочего стенда" на Ubuntu. + +## 0) Что получится + +- локальный LLM через Ollama +- `openclaude` в CLI +- локальный RAG-поиск через MCP (`search_docs`) + +## 1) Установка зависимостей + +```bash +sudo apt update +sudo apt install -y curl git docker.io docker-compose-plugin python3 python3-venv python3-pip direnv +sudo systemctl enable docker +sudo systemctl start docker +``` + +## 2) Установка Ollama + +```bash +curl -fsSL https://ollama.com/install.sh | sh +sudo systemctl enable ollama +sudo systemctl start ollama +curl -s http://127.0.0.1:11434/api/tags +``` + +## 3) Установка OpenClaude + +```bash +npm install -g @gitlawb/openclaude +``` + +## 4) Скачать стартовые модели + +```bash +ollama pull qwen2.5-coder:7b +ollama pull nomic-embed-text +``` + +## 5) Поднять Qdrant + +```bash +cd ~/openclaude-LLM-local/docs/rag-mcp-starter +docker compose up -d +curl -s http://127.0.0.1:6333/collections +``` + +## 6) Подготовить Python окружение + +```bash +cd ~/openclaude-LLM-local/docs/rag-mcp-starter +python3 -m venv .venv +source .venv/bin/activate +pip install --upgrade pip +pip install -r requirements.txt +``` + +## 7) Проиндексировать документы + +```bash +cd ~/openclaude-LLM-local/docs/rag-mcp-starter +mkdir -p docs +# Добавьте в docs/ файлы .md .txt .pdf +python index.py +``` + +## 8) Запустить MCP сервер + +```bash +cd ~/openclaude-LLM-local/docs/rag-mcp-starter +source .venv/bin/activate +python mcp_server.py +``` + +## 9) Подключить MCP в OpenClaude + +Добавьте в `~/.claude/settings.json`: + +```json +{ + "mcpServers": { + "local-rag": { + "command": "/home/USER/openclaude-LLM-local/docs/rag-mcp-starter/.venv/bin/python", + "args": ["/home/USER/openclaude-LLM-local/docs/rag-mcp-starter/mcp_server.py"] + } + } +} +``` + +Замените `USER` на вашего пользователя. + +## 10) Запуск OpenClaude на локальной модели + +```bash +export CLAUDE_CODE_USE_OPENAI=1 +export OPENAI_BASE_URL=http://127.0.0.1:11434/v1 +export OPENAI_MODEL=qwen2.5-coder:7b +openclaude +``` + +Проверка в чате: попросите агента использовать `search_docs` по вашему запросу. diff --git a/docs/rag-mcp-starter/README.md b/docs/rag-mcp-starter/README.md new file mode 100644 index 0000000..0f74367 --- /dev/null +++ b/docs/rag-mcp-starter/README.md @@ -0,0 +1,32 @@ +# rag-mcp-starter + +Минимальный стартовый набор для локального RAG + MCP: + +- `docker-compose.yml` - Qdrant +- `index.py` - индексация документов в Qdrant +- `mcp_server.py` - MCP инструменты `search_docs` и `answer_with_citations` +- `requirements.txt` - Python зависимости + +## Быстрый запуск + +```bash +docker compose up -d +python3 -m venv .venv +source .venv/bin/activate +pip install -U pip +pip install -r requirements.txt +mkdir -p docs +# Добавьте документы в docs/ +python index.py +python mcp_server.py +``` + +## Переменные окружения + +- `QDRANT_URL` (default `http://127.0.0.1:6333`) +- `QDRANT_COLLECTION` (default `docs`) +- `EMBED_MODEL` (default `nomic-embed-text`) +- `GEN_MODEL` (default `qwen2.5-coder:7b`) +- `RAG_DOCS_DIR` (default `./docs`) +- `CHUNK_SIZE` (default `1000`) +- `CHUNK_OVERLAP` (default `120`) diff --git a/docs/rag-mcp-starter/__pycache__/index.cpython-314.pyc b/docs/rag-mcp-starter/__pycache__/index.cpython-314.pyc new file mode 100644 index 0000000..48f0892 Binary files /dev/null and b/docs/rag-mcp-starter/__pycache__/index.cpython-314.pyc differ diff --git a/docs/rag-mcp-starter/__pycache__/mcp_server.cpython-314.pyc b/docs/rag-mcp-starter/__pycache__/mcp_server.cpython-314.pyc new file mode 100644 index 0000000..befb688 Binary files /dev/null and b/docs/rag-mcp-starter/__pycache__/mcp_server.cpython-314.pyc differ diff --git a/docs/rag-mcp-starter/docker-compose.yml b/docs/rag-mcp-starter/docker-compose.yml new file mode 100644 index 0000000..ba06a1d --- /dev/null +++ b/docs/rag-mcp-starter/docker-compose.yml @@ -0,0 +1,12 @@ +services: + qdrant: + image: qdrant/qdrant:latest + container_name: qdrant + ports: + - "6333:6333" + volumes: + - qdrant_storage:/qdrant/storage + restart: unless-stopped + +volumes: + qdrant_storage: diff --git a/docs/rag-mcp-starter/index.py b/docs/rag-mcp-starter/index.py new file mode 100644 index 0000000..4647011 --- /dev/null +++ b/docs/rag-mcp-starter/index.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +import os +import uuid +from pathlib import Path + +import requests +from qdrant_client import QdrantClient +from qdrant_client.models import Distance, PointStruct, VectorParams + +DOCS_DIR = Path(os.getenv("RAG_DOCS_DIR", "./docs")) +QDRANT_URL = os.getenv("QDRANT_URL", "http://127.0.0.1:6333") +COLLECTION = os.getenv("QDRANT_COLLECTION", "docs") +EMBED_MODEL = os.getenv("EMBED_MODEL", "nomic-embed-text") +CHUNK_SIZE = int(os.getenv("CHUNK_SIZE", "1000")) +CHUNK_OVERLAP = int(os.getenv("CHUNK_OVERLAP", "120")) + + +def read_text(path: Path) -> str: + if path.suffix.lower() == ".pdf": + from pypdf import PdfReader + + reader = PdfReader(str(path)) + return "\n".join((p.extract_text() or "") for p in reader.pages) + return path.read_text(encoding="utf-8", errors="ignore") + + +def chunk_text(text: str, size: int, overlap: int) -> list[str]: + chunks = [] + i = 0 + step = max(1, size - overlap) + while i < len(text): + chunks.append(text[i : i + size]) + i += step + return chunks + + +def embed(text: str) -> list[float]: + r = requests.post( + "http://127.0.0.1:11434/api/embeddings", + json={"model": EMBED_MODEL, "prompt": text}, + timeout=120, + ) + r.raise_for_status() + return r.json()["embedding"] + + +def main() -> None: + if not DOCS_DIR.exists(): + print(f"Docs directory not found: {DOCS_DIR}") + return + + client = QdrantClient(url=QDRANT_URL) + test_vec = embed("ping") + dim = len(test_vec) + + if not client.collection_exists(COLLECTION): + client.create_collection( + collection_name=COLLECTION, + vectors_config=VectorParams(size=dim, distance=Distance.COSINE), + ) + + points: list[PointStruct] = [] + supported = {".md", ".txt", ".pdf"} + + for path in DOCS_DIR.rglob("*"): + if not path.is_file() or path.suffix.lower() not in supported: + continue + raw = read_text(path).strip() + if not raw: + continue + for idx, chunk in enumerate(chunk_text(raw, CHUNK_SIZE, CHUNK_OVERLAP)): + vec = embed(chunk) + points.append( + PointStruct( + id=str(uuid.uuid4()), + vector=vec, + payload={ + "source": str(path), + "chunk_id": idx, + "text": chunk[:4000], + }, + ) + ) + + if points: + client.upsert(collection_name=COLLECTION, points=points) + print(f"Indexed chunks: {len(points)}") + else: + print("No documents indexed.") + + +if __name__ == "__main__": + main() diff --git a/docs/rag-mcp-starter/mcp_server.py b/docs/rag-mcp-starter/mcp_server.py new file mode 100644 index 0000000..45425c1 --- /dev/null +++ b/docs/rag-mcp-starter/mcp_server.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +import os + +import requests +from fastmcp import FastMCP +from qdrant_client import QdrantClient + +MCP_NAME = os.getenv("MCP_NAME", "local-rag") +QDRANT_URL = os.getenv("QDRANT_URL", "http://127.0.0.1:6333") +COLLECTION = os.getenv("QDRANT_COLLECTION", "docs") +EMBED_MODEL = os.getenv("EMBED_MODEL", "nomic-embed-text") +GEN_MODEL = os.getenv("GEN_MODEL", "qwen2.5-coder:7b") + +mcp = FastMCP(MCP_NAME) +qdrant = QdrantClient(url=QDRANT_URL) + + +def embed(text: str) -> list[float]: + r = requests.post( + "http://127.0.0.1:11434/api/embeddings", + json={"model": EMBED_MODEL, "prompt": text}, + timeout=120, + ) + r.raise_for_status() + return r.json()["embedding"] + + +def retrieve(query: str, top_k: int = 5): + query_vec = embed(query) + return qdrant.search(collection_name=COLLECTION, query_vector=query_vec, limit=top_k) + + +def sanitize(text: str, max_len: int = 1800) -> str: + return (text or "").strip()[:max_len] + + +@mcp.tool +def search_docs(query: str, top_k: int = 5) -> str: + hits = retrieve(query, top_k=top_k) + if not hits: + return "No relevant docs found." + lines = [] + for i, hit in enumerate(hits, start=1): + src = hit.payload.get("source", "unknown") + txt = sanitize(hit.payload.get("text", "")) + lines.append(f"[{i}] source={src}\n{txt}") + return "\n\n---\n\n".join(lines) + + +@mcp.tool +def answer_with_citations(query: str, top_k: int = 5) -> str: + hits = retrieve(query, top_k=top_k) + if not hits: + return "Не нашел релевантных документов." + + context = [] + citations = [] + for i, hit in enumerate(hits, start=1): + src = hit.payload.get("source", "unknown") + txt = sanitize(hit.payload.get("text", "")) + context.append(f"[{i}] SOURCE: {src}\n{txt}") + citations.append(f"[{i}] {src}") + + prompt = ( + "Ты отвечаешь только на основе контекста.\n" + "Если данных недостаточно, явно скажи об этом.\n" + "Добавь ссылки на источники в формате [номер].\n\n" + f"Вопрос: {query}\n\n" + "Контекст:\n" + + "\n\n".join(context) + ) + + r = requests.post( + "http://127.0.0.1:11434/api/generate", + json={"model": GEN_MODEL, "prompt": prompt, "stream": False}, + timeout=240, + ) + r.raise_for_status() + answer = r.json().get("response", "").strip() + + return f"{answer}\n\nИсточники:\n" + "\n".join(citations) + + +if __name__ == "__main__": + mcp.run() diff --git a/docs/rag-mcp-starter/requirements.txt b/docs/rag-mcp-starter/requirements.txt new file mode 100644 index 0000000..6e91b16 --- /dev/null +++ b/docs/rag-mcp-starter/requirements.txt @@ -0,0 +1,4 @@ +fastmcp +qdrant-client +requests +pypdf