Add quickstart and RAG MCP starter docs.

Made-with: Cursor
This commit is contained in:
Andrey Lutsenko
2026-04-20 22:55:26 +10:00
parent f5c6ce6169
commit d696623f31
9 changed files with 334 additions and 0 deletions
+32
View File
@@ -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`)
+12
View File
@@ -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:
+93
View File
@@ -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()
+85
View File
@@ -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()
+4
View File
@@ -0,0 +1,4 @@
fastmcp
qdrant-client
requests
pypdf