Як я перестав з’їдати токени на великій базі markdown-файлів
Проблема
Я використовую Claude Code з PRO-підпискою як основний робочий інструмент. Мій проєкт — база знань на markdown-файлах під git. Почав з 20 файлів, зараз 150+, росте щодня.
Claude Code працює так: на початку сесії він читає CLAUDE.md (індексний файл), потім — файли на які той посилається, потім — файли потрібні для конкретної задачі. При 20 файлах це працює ідеально. При 150+ — починається проблема.
Opus 4.6 — найсильніша модель, дає найкращу якість роботи. Але вона дорога на токени. На великій базі файлів Claude читає все що вважає потрібним, і PRO-ліміт вичерпується за
Sonnet 4.6 — економніша, токенів вистачає на повноцінну сесію. Але якість нижча. Не драматично — але помітно на задачах де потрібен глибокий контекст, розуміння зв’язків між файлами, синтез інформації з різних місць.
Класичний trade-off: якість або кількість. Обирай.
Чому grep не вирішує
Claude Code вже має вбудований пошук — Grep і Glob. Grep шукає по точних словах. Це працює коли знаєш що шукаєш: назву файлу, змінну, конкретний термін.
Але коли задача — «знайди де в проєкті обговорювалась зміна архітектури зберігання» або «які рішення приймались щодо інтеграції між підпроєктами» — grep безсилий. Тому що ці речі описані різними словами в різних файлах. Claude компенсує це тим, що читає багато файлів підряд — і саме це з’їдає токени.
Потрібен пошук за змістом, а не за словами.
Рішення: локальний MCP-сервер з семантичним пошуком
MCP (Model Context Protocol) — це стандарт Anthropic для підключення зовнішніх інструментів до Claude. Claude Code підтримує MCP-сервери «з коробки» — достатньо покласти .mcp.json у корінь проєкту.
Ідея проста: замість того щоб Claude читав 150 файлів шукаючи релевантний контекст — він робить один запит до локальної векторної бази і отримує
Стек
- ChromaDB — локальна векторна база, без сервера, файл на диску
- sentence-transformers — бібліотека для embeddings
- paraphrase-multilingual-MiniLM-L12-v2 — модель що працює з різними мовами (у моєму випадку українська + англійська)
- FastMCP — бібліотека Anthropic для створення MCP-серверів
Все локальне. Нічого в хмару не йде. Embeddings генеруються на CPU, зберігаються на диску.
Структура
project/ .mcp.json ← конфігурація MCP-сервера vector/ server.py ← MCP-сервер (96 рядків) index.py ← скрипт індексації (127 рядків) requirements.txt ← 3 залежності venv/ ← Python-середовище (локальне) chroma_db/ ← база embeddings (локальна)
Жоден існуючий файл проєкту не змінюється. Додається тільки нове.
Конфігурація: .mcp.json
{
"mcpServers": {
"project-search": {
"command": "/path/to/project/vector/venv/bin/python3",
"args": ["/path/to/project/vector/server.py"]
}
}
}
Claude Code при старті читає цей файл і запускає сервер автоматично.
Сервер: server.py
#!/usr/bin/env python3
"""MCP server for semantic search."""
import os
import chromadb
from sentence_transformers import SentenceTransformer
from mcp.server.fastmcp import FastMCP
CHROMA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "chroma_db")
COLLECTION_NAME = "project"
MODEL_NAME = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
mcp = FastMCP("project-search")
_model = None
_collection = None
def get_model():
global _model
if _model is None:
_model = SentenceTransformer(MODEL_NAME)
return _model
def get_collection():
global _collection
if _collection is None:
client = chromadb.PersistentClient(path=CHROMA_DIR)
_collection = client.get_collection(COLLECTION_NAME)
return _collection
@mcp.tool()
def semantic_search(query: str, n_results: int = 5) -> str:
"""Search project knowledge base by meaning, not just keywords.
Args:
query: Natural language question or topic to search for
n_results: Number of results to return (default 5)
"""
model = get_model()
collection = get_collection()
embedding = model.encode([query]).tolist()
results = collection.query(
query_embeddings=embedding,
n_results=n_results,
include=["documents", "metadatas", "distances"],
)
output = []
for i in range(len(results["ids"][0])):
source = results["metadatas"][0][i]["source"]
distance = results["distances"][0][i]
relevance = round((1 - distance) * 100, 1)
text = results["documents"][0][i]
output.append(f"### [{source}] (relevance: {relevance}%)\n{text}")
if not output:
return "No results found."
return "\n\n---\n\n".join(output)
@mcp.tool()
def reindex() -> str:
"""Reindex all files. Run after significant changes."""
import subprocess
index_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.py")
result = subprocess.run(
["python3", index_script],
capture_output=True, text=True,
)
global _collection
_collection = None
if result.returncode != 0:
return f"Reindex failed:\n{result.stderr}"
return result.stdout
if __name__ == "__main__":
mcp.run()
Індексація: index.py
#!/usr/bin/env python3
"""Index all .md files into ChromaDB for semantic search."""
import os
import hashlib
import chromadb
from sentence_transformers import SentenceTransformer
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CHROMA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "chroma_db")
MODEL_NAME = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
def find_md_files(root_dir):
md_files = []
skip = {".git", "vector", "venv", "__pycache__", "node_modules"}
for dirpath, dirnames, filenames in os.walk(root_dir):
dirnames[:] = [d for d in dirnames if d not in skip]
for f in filenames:
if f.endswith(".md"):
md_files.append(os.path.join(dirpath, f))
return md_files
def split_into_chunks(text, filepath, chunk_size=500, overlap=50):
chunks = []
paragraphs = text.split("\n\n")
current_chunk = ""
current_start = 0
char_pos = 0
for para in paragraphs:
if len(current_chunk) + len(para) > chunk_size and current_chunk:
chunks.append({
"text": current_chunk.strip(),
"source": filepath,
"chunk_id": f"{filepath}:{current_start}",
})
words = current_chunk.split()
overlap_text = " ".join(words[-overlap:]) if len(words) > overlap else ""
current_chunk = overlap_text + "\n\n" + para
current_start = char_pos
else:
current_chunk += "\n\n" + para if current_chunk else para
char_pos += len(para) + 2
if current_chunk.strip():
chunks.append({
"text": current_chunk.strip(),
"source": filepath,
"chunk_id": f"{filepath}:{current_start}",
})
return chunks
def index():
print(f"Loading model {MODEL_NAME}...")
model = SentenceTransformer(MODEL_NAME)
client = chromadb.PersistentClient(path=CHROMA_DIR)
try:
client.delete_collection("project")
except Exception:
pass
collection = client.get_or_create_collection(
name="project",
metadata={"hnsw:space": "cosine"},
)
md_files = find_md_files(ROOT_DIR)
print(f"Found {len(md_files)} .md files")
all_chunks = []
for filepath in md_files:
rel_path = os.path.relpath(filepath, ROOT_DIR)
try:
with open(filepath, "r", encoding="utf-8") as f:
text = f.read()
except Exception:
continue
if not text.strip():
continue
all_chunks.extend(split_into_chunks(text, rel_path))
print(f"Total chunks: {len(all_chunks)}")
batch_size = 64
for i in range(0, len(all_chunks), batch_size):
batch = all_chunks[i:i + batch_size]
texts = [c["text"] for c in batch]
embeddings = model.encode(texts).tolist()
ids = [hashlib.md5(c["chunk_id"].encode()).hexdigest() for c in batch]
metadatas = [{"source": c["source"]} for c in batch]
collection.add(
ids=ids, embeddings=embeddings,
documents=texts, metadatas=metadatas,
)
print(f"Done! {len(all_chunks)} chunks indexed.")
if __name__ == "__main__":
index()
Розгортання: 3 команди
python3 -m venv vector/venv vector/venv/bin/pip install -r vector/requirements.txt vector/venv/bin/python3 vector/index.py
Перезапускаєте Claude Code — і інструмент semantic_search доступний автоматично. Claude бачить його в списку інструментів і використовує коли потрібен контекстний пошук.
Результат
До: Opus 4.6 на 150 файлах — ліміт PRO за
Після: Opus 4.6 з семантичним пошуком. Claude робить
Бонус який не чекав: якість контексту навіть вища. Grep знаходить файли де є потрібне слово. Семантичний пошук знаходить фрагменти де обговорюється потрібна тема — навіть якщо використані зовсім інші слова. Claude отримує точніший контекст і дає кращі відповіді.
Приклад
Запит: «які рішення приймались щодо структури зберігання даних»
Grep знайде файли де є слово «зберігання». Семантичний пошук поверне:
- фрагмент з нотатки де обговорювалась міграція між форматами
- рішення про дворівневу архітектуру (сирі дані + синтез)
- порівняння підходів з research-файлу
Три різних файли, жоден не містить слова «зберігання» — але всі релевантні.
Коли це потрібно
- 50+ файлів у робочому контексті — менше того grep справляється
- PRO-підписка з Opus — де ліміт токенів відчутний
- Різномовний контент — модель працює з 50+ мовами
- Контент де одна тема описується різними словами в різних місцях — документація, нотатки, логи рішень
Коли це НЕ потрібно
- Маленький проєкт з кількома файлами — CLAUDE.md як індекс достатньо
- Чисто код без документації — grep по іменах функцій працює краще
- Якщо вистачає Sonnet — не ламайте те що працює
Технічні деталі
- Модель: paraphrase-multilingual-MiniLM-L12-v2 (~120MB, CPU)
- Індексація: 150 файлів → ~3000 чанків, ~30 секунд на CPU
- Чанки: 500 символів з overlap 50, розбиття по параграфах
- Переіндексація: вручну, раз на тиждень або коли пошук не знаходить нове
- Все локальне: нічого в хмару, ніяких API-ключів, ніяких платних сервісів
Висновок
223 рядки Python-коду. Три залежності. Три команди для розгортання. Результат — Opus 4.6 знову доступний для повноцінної роботи на великій базі файлів.
MCP — це не космічна технологія. Це спосіб дати Claude інструмент якого йому не вистачає. В цьому випадку — пам’ять яка не з’їдає контекст.
9 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів