Як я перестав з’їдати токени на великій базі markdown-файлів

💡 Усі статті, обговорення, новини про AI — в одному місці. Приєднуйтесь до AI спільноти!

Проблема

Я використовую Claude Code з PRO-підпискою як основний робочий інструмент. Мій проєкт — база знань на markdown-файлах під git. Почав з 20 файлів, зараз 150+, росте щодня.

Claude Code працює так: на початку сесії він читає CLAUDE.md (індексний файл), потім — файли на які той посилається, потім — файли потрібні для конкретної задачі. При 20 файлах це працює ідеально. При 150+ — починається проблема.

Opus 4.6 — найсильніша модель, дає найкращу якість роботи. Але вона дорога на токени. На великій базі файлів Claude читає все що вважає потрібним, і PRO-ліміт вичерпується за 5-10 хвилин активної роботи. Не встигаєш нічого зробити.

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 файлів шукаючи релевантний контекст — він робить один запит до локальної векторної бази і отримує 5-8 найрелевантніших фрагментів. Замість тисяч токенів на читання — сотні.

Стек

  • 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 за 5-10 хвилин. Або Sonnet з нижчою якістю.

Після: Opus 4.6 з семантичним пошуком. Claude робить 2-3 запити до бази замість читання десятків файлів. Токени йдуть на роботу, а не на орієнтацію.

Бонус який не чекав: якість контексту навіть вища. 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 інструмент якого йому не вистачає. В цьому випадку — пам’ять яка не з’їдає контекст.

👍ПодобаєтьсяСподобалось16
До обраногоВ обраному12
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Може станеться в нагодi. Пише щоб не переривати роботу Claud Code можна пiд час виконання робити окремi запити, або правити, не втрачаючи контексту /btw youtu.be/...​0woDY?si=2e63G-_QFbxCqkvQ

Хороша штука має бути для всіх, хто користує Obsidian. Якраз шукав як то все спаяти з ШІ. В мене 5000+ файлів. Ніяких токенів не напастись.

Індексація 5000 файлів це може бути довга історія, потрібне гарне залізо, проте це точно варто того щоб спробувати. Найпростіший рецепт — це завести собі Clode code і кинути йому посилання на цю статтю — і пояснити свою ситуацію у двох словах. Решту він зробить сам.

Ого! 5000 файлів!
Запізніла реакція :)

Саме головне, що Claude і ви зможете спілкуватись зі своєю базою так, як ви з ChatGPT тільки на своїх даних.

Я Індексував якимось ШІ плагіном через gpt-embeding по api, довго але впоралось, коштувало 3 доляри, щоправда плагін був тупуватий і реально погано шукав. Що цікавого — можна було налаштувати оновлення, він дивився які файли додавались які змінювались і робив додатковий індекс, дельту. Не знаю чи у вас це реалізовано, я погано розумію python. Якщо ні — автоматичне оновлення корисна річ, а коли файлів багато — то обовязково додатково дельту індексувати.

хм виглядає цікаво, маю уточнюючі питання
— тобто ви умовно «примусово» просите використовувати свій мсп для пошуку того що потрібно саме по мд файлам?
— І справі звичайного грепу від клоду не вистачає?
— Наскільки краще так відбувається пошук?

Привіт! Дякую за питання.

Примусово — ні. Claude сам вирішує коли використовувати semantic_search, а коли grep. В CLAUDE.md є підказка: «Grep — для конкретних слів, semantic_search — для питань.» Але це рекомендація, не примус. На практиці Claude швидко розуміє різницю і обирає правильний інструмент сам (іноді, дуже рідко, помиляється).

Чи не вистачає grep? Для коду — вистачає. Для текстових markdown-файлів — ні. Приклад: шукаю все що стосується «вигорання на проєкті». Grep знайде файл де є слово «вигорання». Але не знайде файл де написано «втратив мотивацію після релізу» або «перестав відчувати сенс в задачах». Це та сама тема, але іншими словами. Семантичний пошук знаходить за змістом, не за словом.

Наскільки краще? Два виміри:
Точність — замість читання 20-30 файлів щоб знайти контекст, Claude робить 2-3 запити і отримує саме ті фрагменти які потрібні. Менше шуму.
Токени — це головне. Opus на PRO-підписці обмежений. Без семантичного пошуку він з’їдає ліміт за 5-10 хвилин бо читає файл за файлом. З ним — працює повноцінну сесію.

Але є нюанс: це має сенс тільки коли файлів 50+ і вони текстові. Для невеликого проєкту з кодом — overkill.

PS — якщо буде бажання створити щось подібне — я б не рекомендував робити це все самому — дайте посилання Claude code на цю статтю — і скажіть — зроби так. Він зробить :)

дякую! цікаво! Це класна ідея я српобує це прикрутити для тест кейсів, пошуку їх що стосуються нового або старого функціоналу. виглядає що може допомогти такий підхід та компоненти

Подивися на готове рішення — MCP server Serena

Дякую, подивився. Serena — сильна штука, але для іншої задачі. Вона працює через LSP — семантика коду (символи, визначення, рефакторинг). Coding agent.

У мене задача інша: 150+ markdown-файлів з контентом — щоденники, нотатки, проєктна документація, кілька мов. Тут не code navigation потрібен, а semantic search по тексту. Тому ChromaDB + sentence-transformers — вони саме для цього.

Але дякую за коментар — для кодових проєктів Serena виглядає цікаво.

Підписатись на коментарі