Add quickstart and RAG MCP starter docs.
Made-with: Cursor
This commit is contained in:
@@ -352,3 +352,8 @@ chmod +x script.sh
|
|||||||
4. Запуск минимального RAG с Qdrant
|
4. Запуск минимального RAG с Qdrant
|
||||||
5. Добавление MCP-инструментов под ваши рабочие данные
|
5. Добавление MCP-инструментов под ваши рабочие данные
|
||||||
6. Оптимизация скорости и стоимости (routing между моделями)
|
6. Оптимизация скорости и стоимости (routing между моделями)
|
||||||
|
|
||||||
|
## Дополнительные материалы в репозитории
|
||||||
|
|
||||||
|
- Быстрый гайд: `docs/quickstart-30min.md`
|
||||||
|
- Готовый starter: `docs/rag-mcp-starter/`
|
||||||
|
|||||||
@@ -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` по вашему запросу.
|
||||||
@@ -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`)
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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:
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fastmcp
|
||||||
|
qdrant-client
|
||||||
|
requests
|
||||||
|
pypdf
|
||||||
Reference in New Issue
Block a user