From d696623f31e203797708048ca2e4568b1f624251 Mon Sep 17 00:00:00 2001 From: Andrey Lutsenko Date: Mon, 20 Apr 2026 22:55:26 +1000 Subject: [PATCH] Add quickstart and RAG MCP starter docs. Made-with: Cursor --- README.md | 5 + docs/quickstart-30min.md | 103 ++++++++++++++++++ docs/rag-mcp-starter/README.md | 32 ++++++ .../__pycache__/index.cpython-314.pyc | Bin 0 -> 5300 bytes .../__pycache__/mcp_server.cpython-314.pyc | Bin 0 -> 5344 bytes docs/rag-mcp-starter/docker-compose.yml | 12 ++ docs/rag-mcp-starter/index.py | 93 ++++++++++++++++ docs/rag-mcp-starter/mcp_server.py | 85 +++++++++++++++ docs/rag-mcp-starter/requirements.txt | 4 + 9 files changed, 334 insertions(+) create mode 100644 docs/quickstart-30min.md create mode 100644 docs/rag-mcp-starter/README.md create mode 100644 docs/rag-mcp-starter/__pycache__/index.cpython-314.pyc create mode 100644 docs/rag-mcp-starter/__pycache__/mcp_server.cpython-314.pyc create mode 100644 docs/rag-mcp-starter/docker-compose.yml create mode 100644 docs/rag-mcp-starter/index.py create mode 100644 docs/rag-mcp-starter/mcp_server.py create mode 100644 docs/rag-mcp-starter/requirements.txt 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 0000000000000000000000000000000000000000..48f08922211a538188fcb1541b4dc09816c521b1 GIT binary patch literal 5300 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 RH{TJ&Uk%;kkdr2yn?5Nh(p6DpcZ< zR6f~Xp^}FA)nwSc1$Km4%f-cc05nWNG%~N39Q|`@Evx) z%)1%RZY}$VSALCC17Xf`_r-MMK=&YYeTPP9Of|Zb2}Lz54z34eU{7yP^uWQM-u>f2RU1phTa>Y5%1Dc$oHZ=(-rj-o2fLXJ9iyatK@+)8qY8|7qf2xjaiJBCU|N61jwq{$=8 z%kzY^Io6Ue|20xs0u#tZ6(dd6FmLg?K`kMf&<)FZJgLPDkjHggqhm2cM{(9zYjB5c zprMwrc&bG=V$@J*8}y^PLQgBSHFeJNMWZoQ)r^>-M5DAG*44mYUj-mTz9VjK^&Emr zd=Ve-2=6@muEa2Rfr7BTV+WfX{%r4WK_^4z_c>VEC6d_wHf)ZIl8cXO(6iy=w zYB$n4T>&omdip1%6lthh>%%_DauLPD4vYPhNJ=AR_sM3dTpl*@AxdYXLn8RdVl#}!V|j+(h*ZQ za_=!#bn-ZG`7sej=I1@KjRy?~>_}~sbdSZ(Mw5z4tAH!r0N_Cfx*Fzn1Q;SXZqpos zOPxi0yjRN}AHt!+=-x9$ov<^t!w|RXc zg>p@kat&1i_>bV=lwD;Gl`!Cr@PpEVSC_i7J0kdq?1{JldLwQh>2Q_TG?S-~5#lAQ zh%63bbX(LSe3=rAO}fl6_a1K?&_Yy6DyN|+31NZ_9oN$8Nb66MNN+>6tc19PQ}haS|73a z6A(Dicr8~fl~U9Z$VXa24SN;@9bq&983+QFs^}J%uy`0lg+SvLZ=5xB53Ghj#G36E zk{!jOxC6Q$rZINulK?X0A5!g9?=9)!OwZq>s?V!7&iVqG-a9obay>KL?V3%Kt_f~7 zP@O$Fc`~a_YPr!FZz0ez!QTlqOzCgG{N~GVzw+iQ)2C->p|PV7=$znZJ^rk3(l>P~ zSDoW?JExzS?wzTb>7LnY*0+D=>6mkq+Ra4|@h!7GF{c!yb*8ZHCv-x(12+0!!(1n- z1RW2<+$wG_q;eB+`? z7?$Vn8xHH{gW2G+rPrpUhy?qt?{LW8`|i7CyQzH zN$8D>!=YymJ}YMK5n^mV%D-XW zFmE!Mkmx)J2QNb@0nOfh4J0`a#8+BH^LIe-k$D{j1bNHSegaa0#5NVOg|`5kM?(25 zu!3F)mfe6Af5fFX%@0EP->|oT3@k4p0_LuwNax{q5#DeE2|-OE^E%^dbG&ZjuZHrk z!8i0<4Ea&K4DADi<&JBreL0RN;Bo`SvrhLRc|U@u5Il_l zWu{ngLEcjJGYZx5<4ZXWVJ@;$QN2AIJ(Dm-qw_2e12L=SI#|`RC@CHIG7!w{oekDN z+5G=xrmAdUG5~q0J-0UZ!VEv1p4l^_&nRZ&mRp{!v!3ePo~FE~38oL_x^r}T?R0%^ z)n}fKb6(6@0p7c;=pxITb8aYlL#v8#HCqK2GpOS?Pi~$%Q4m*sD>=Q*MV>gNZ)%9Q z+P>1Y;Fa#&^v0RKEBmf1roU&&Yn?xkjZ8)^KA#<$9LjB)K34FDZ~NQw{yWO<^|u~fMF!M`pcMg@Zp>mV9+zX>VK*h) zHr?{B&$zyI z!S{~u>hPlaod`D>C{P^E*PG5a{mTKh_FbMh#3J^JO%*&A1*Hk>7WZ&O@J)>0;+M?| T?$`I8-}~$SIi3j1*{=Ts2C9GB literal 0 HcmV?d00001 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