Bollywood HungamaJanhvi Kapoor, Sreeleela and Siddharth Gupta team up for Umesh Bisht’s thriller, first schedule begins in NainitalInquirerTaal Volcano records 17 earthquakes in 24 hoursCNN TürkTaksitli alışverişlerde yeni dönem: Cep telefonlarında 12 taksit sınırı değiştiPunchPolice remove Rivers DPO after station renovation by suspected cultistsDaily MaverickKATLEHONG MASS SHOOTING: Girl, 11, who survived mass shooting still has bullet lodged near her spineRTP DesportoFrancisco Cabral na final de pares do Masters 1.000 de XangaiThe Jerusalem PostIDF blocks roads leading to Gaza border after Nahala settlers attempt to build outpost overnightEgypt IndependentTemperatures drop 3°C as rain reaches CairoMalay MailFahmi: No Malaysian palm oil companies involved in burning activities in IndonesiaColliderApple TV's New Crown Jewel Officially Continues Streaming DominationSouth China Morning PostChina’s AI boom, self-sufficiency push fuel earnings surge for local chip firmsSky TG24Sciopero scuola e blocco autotrasportatori: giornata di mobilitazione da Nord a Sud. LIVE
The Daily Newsstand · Free, Always
Friday, October 16, 2026

Локальный семантический поиск по заметкам как MCP-сервер: нарезка под лимит модели и кэш

Translate

У меня есть папка с конспектами созвонов и расшифровками встреч, и искать в ней по слову неудобно: я помню, что обсуждали найм, а в файле написано «собеседование» и «кандидат», и обычный поиск по тексту такой файл не находит. Отправлять заметки во внешний сервис ради эмбеддингов мне не хотелось, поэтому я собрал маленький MCP-сервер, который ищет по смыслу прямо на моей машине, и подключил его к Claude Code. Всё работает без платных API, а заметки не покидают компьютер.

В статье разберу, как устроен движок и сервер, и три места, на которых легко споткнуться: молчаливое обрезание текста моделью, лишний вывод в стандартный поток, из-за которого ломается протокол, и проверка пути в инструменте чтения. Отдельно покажу кэш, который сам понимает, что устарел. Код ниже взят из рабочих файлов, лишнее я убрал.

Что получилось

Два файла на Python и папка с заметками:

search_index.py   # движок: нарезка, эмбеддинги, кэш, поиск
server.py         # MCP-сервер: три инструмента поверх движка
notes/            # сами заметки, .md и .txt
.index/           # кэш, создаётся сам, в git не попадает

Сервер отдаёт Claude Code три инструмента:

  • search_notes(query, limit=5) ищет по смыслу и возвращает фрагменты с именем файла и оценкой близости;

  • list_notes() показывает список заметок с размером и датой изменения;

  • read_note(filename) отдаёт заметку целиком.

Все три только читают, и я так и пометил их в коде, чтобы клиент мог вызывать их без лишних вопросов.

Для тех, кто с MCP не работал: это протокол, по которому программа вроде Claude Code запускает вашу маленькую программу-сервер и спрашивает у неё, какие инструменты она умеет. Дальше модель сама решает, когда какой вызвать. Общаются они через стандартный ввод и вывод, никакого сетевого порта открывать не надо.

Стек и версии

[project]
requires-python = ">=3.11"
dependencies = [
    "mcp==1.27.2",
    "sentence-transformers==5.5.1",
    "numpy==2.4.6",
]

[tool.uv]
package = false

Версии я зафиксировал жёстко. package = false говорит uv, что это приложение для прямого запуска и собирать его как пакет не надо. Минимальный Python 3.11 появился из-за numpy 2.4.6: я сначала поставил >=3.10, а менеджер пакетов напомнил, что эта версия numpy 3.10 уже не поддерживает.

Эмбеддинги (это набор чисел, по которому сравнивают смысл текстов) считает модель paraphrase-multilingual-MiniLM-L12-v2. Она весит около 120 МБ, знает больше пятидесяти языков, включая русский и английский, и выдаёт векторы длиной 384. Скачивается один раз при первом запуске, потом работает без интернета. Вместе с sentence-transformers ставится PyTorch, это ещё один-два гигабайта на диске, один раз.

В плане у меня была модель покрупнее, paraphrase-multilingual-mpnet-base-v2. Она точнее, но около гигабайта и медленнее. Для нескольких десятков заметок такая точность мне не нужна, поэтому она осталась в списке «если не хватит».

Грабля первая: модель молча режет длинный вход

У MiniLM жёсткий лимит в 128 токенов на вход (токеном называют кусок слова, которым модель «читает» текст). Всё, что длиннее, она отбрасывает без предупреждения: ошибки нет, поиск работает, просто хвост каждого длинного фрагмента в индексе не участвует.

Сначала я собирался резать заметки по 400–600 символов, как обычно советуют. Но у русского текста токенов на символ выходит больше, чем у английского, и 600 символов могут не поместиться в 128 токенов, а с ними потерялся бы и хвост фрагмента.

Поэтому главным ограничителем я сделал токены, а символы оставил ориентиром. Считает токены токенайзер самой модели, и лимит я тоже читаю из модели:

CHAR_TARGET = 500      # ориентир размера фрагмента (символы)
CHAR_MAX = 600         # верхняя граница фрагмента (символы)
CHAR_OVERLAP = 80      # перекрытие между соседними фрагментами (символы)
TOKEN_MARGIN = 4       # запас под спец-токены модели ([CLS]/[SEP] и т.п.)
FALLBACK_MAX_SEQ = 128 # если у модели вдруг не считался лимит

...
self.model = SentenceTransformer(MODEL_NAME)
self.tokenizer = self.model.tokenizer
max_seq = int(getattr(self.model, "max_seq_length", FALLBACK_MAX_SEQ) or FALLBACK_MAX_SEQ)
self.token_cap = max(8, max_seq - TOKEN_MARGIN)

Запас в четыре токена нужен под служебные метки, которые модель сама добавляет к началу и концу текста. Итого потолок на фрагмент получается 124 токена.

Сама нарезка идёт в два прохода. Сначала текст разбивается на «кирпичики» по строкам; слишком длинную строку я режу по концам предложений, а слишком длинное предложение по символам:

def _split_units(text: str) -> list[str]:
    units: list[str] = []
    for line in text.split("\n"):
        line = line.strip()
        if not line:
            continue
        if len(line) <= CHAR_MAX:
            units.append(line)
            continue
        for part in re.split(r"(?<=[.!?…])\s+", line):
            part = part.strip()
            if not part:
                continue
            if len(part) <= CHAR_MAX:
                units.append(part)
            else:
                for i in range(0, len(part), CHAR_MAX):
                    units.append(part[i:i + CHAR_MAX])
    return units

Потом каждый кирпичик проверяется на токены. Если он сам не влезает, его режут уже точно по токенам, через encode и decode:

def _split_by_tokens(tokenizer, text: str, token_cap: int) -> list[str]:
    ids = tokenizer.encode(text, add_special_tokens=False)
    if len(ids) <= token_cap:
        return [text]
    pieces = []
    for i in range(0, len(ids), token_cap):
        piece = tokenizer.decode(ids[i:i + token_cap], skip_special_tokens=True).strip()
        if piece:
            pieces.append(piece)
    return pieces

Второй проход жадно складывает кирпичики во фрагменты и проверяет оба ограничения сразу, и символьное, и токенное. Когда фрагмент заполнился, в начало следующего переносится хвост предыдущего на 80 символов, чтобы мысль, разрезанная посередине, не терялась:

chunks: list[str] = []
cur = ""
for unit in safe_units:
    candidate = f"{cur} {unit}".strip() if cur else unit
    too_long = len(candidate) > CHAR_MAX or _count_tokens(tokenizer, candidate) > token_cap
    if cur and too_long:
        chunks.append(cur)
        tail = cur[-CHAR_OVERLAP:]
        start = f"{tail} {unit}".strip()
        if len(start) <= CHAR_MAX and _count_tokens(tokenizer, start) <= token_cap:
            cur = start
        else:
            cur = unit
    else:
        cur = candidate
if cur:
    chunks.append(cur)

Число токенов считается заново на каждом шаге, это не самое быстрое решение, зато код остаётся простым. Скорость нарезки я не замерял.

На трёх первых тестовых заметках получилось 12 фрагментов. Размер по символам вышел от 323 до 492, в среднем 414. По токенам от 88 до 123 при потолке 124. Выходит, на русском тексте токенный потолок срабатывает раньше символьного, и фрагменты получаются короче «целевых» 500 символов, но ничего не обрезается. Эти цифры я брал из статистики самотеста.

Кэш, который сам понимает, что устарел

Считать эмбеддинги для всех заметок при каждом запросе незачем. Я кладу их в файл .index/cache.pkl, а ключом служит относительный путь заметки плюс время её изменения. Одного имени файла не хватило бы, ведь в разных подпапках могут лежать файлы с одинаковым названием.

Но у кэша есть вторая причина стать недействительным: если я поменяю модель или параметры нарезки, старые векторы будут от другой нарезки и сравнивать с ними нельзя. Для этого в кэш записан словарь параметров, и при любом расхождении кэш собирается с нуля:

def _current_params(token_cap: int) -> dict:
    return {
        "version": CACHE_VERSION,
        "model": MODEL_NAME,
        "char_target": CHAR_TARGET,
        "char_max": CHAR_MAX,
        "char_overlap": CHAR_OVERLAP,
        "token_cap": token_cap,
    }

Поле version я завёл на случай, когда поменяю саму структуру кэша: увеличиваю число, и все старые файлы считаются чужими.

Запись кэша идёт через временный файл с последующей заменой, чтобы сбой посреди записи не оставил полуфайл:

def _save_cache(cache: dict) -> None:
    INDEX_DIR.mkdir(exist_ok=True)
    tmp = CACHE_FILE.with_suffix(".tmp")
    with open(tmp, "wb") as fh:
        pickle.dump(cache, fh)
    tmp.replace(CACHE_FILE)  # атомарная замена

Битый кэш при чтении не роняет сервер: ловится исключение, пишется предупреждение, индекс строится заново.

При сборке индекса для каждого файла сравнивается время изменения, и пересчитываются только изменившиеся:

for rel, abs_path, mtime in notes:
    current_rel.add(rel)
    entry = files_cache.get(rel)
    if entry and entry["mtime"] == mtime:
        file_chunks = entry["chunks"]
        file_emb = entry["embeddings"]
    else:
        text = _read_text(abs_path, rel)
        if text is None or not text.strip():
            files_cache.pop(rel, None)
            continue
        texts = _chunk_text(self.tokenizer, text, self.token_cap)
        if not texts:
            files_cache.pop(rel, None)
            continue
        file_emb = self._embed(texts)
        file_chunks = [
            {"rel_path": rel, "index": i + 1, "text": t,
             "n_tokens": _count_tokens(self.tokenizer, t)}
            for i, t in enumerate(texts)
        ]
        files_cache[rel] = {"mtime": mtime, "chunks": file_chunks, "embeddings": file_emb}
        n_recomputed += 1

    chunk_meta.extend(file_chunks)
    emb_blocks.append(file_emb)

for rel in list(files_cache.keys()):
    if rel not in current_rel:
        del files_cache[rel]

Последний цикл убирает из кэша файлы, которых на диске уже нет, поэтому удалённая заметка пропадает из поиска сама.

Дальше матрица склеивается в один массив, np.vstack(emb_blocks). Если заметок нет совсем, создаётся пустая матрица нужной формы, (0, 384), чтобы код поиска не ломался на пустой папке.

Нагляднее всего это видно на проверке. Я добавил четвёртую заметку про маркетинговую кампанию и повторил поиск, не вводя никаких команд переиндексации. В логе появилось «файлов 4, пересчитано файлов: 1»: движок досчитал только новый файл, и заметка сразу нашлась по запросу про продвижение и рекламу нового тарифа в соцсетях. Повторный прогон без изменений дал «пересчитано файлов: 0».

Правда, время изменения файла признак слабый: если содержимое поменялось, а mtime по какой-то причине совпал (например, файл восстановили из архива с сохранением даты), кэш этого не заметит. На своих заметках я с этим не сталкивался, поэтому хэш содержимого добавлять не стал.

Поиск: косинусная близость и фоллбэк по словам

Эмбеддинги я считаю с normalize_embeddings=True. Тогда векторы имеют единичную длину, и косинусная близость сводится к обычному скалярному произведению: умножаю матрицу фрагментов на вектор запроса и получаю близость ко всем фрагментам разом.

q_emb = self.model.encode([query], normalize_embeddings=True, convert_to_numpy=True)[0]
sims = self.embeddings @ q_emb

Чистый семантический поиск плохо работает с точными названиями, числами и редкими терминами. Запрос из одного слова, например имя компании, у эмбеддинг-модели часто даёт расплывчатый результат: она ловит общую тему и теряет конкретное слово. Поэтому я добавил запасной поиск по словам, который включается в двух случаях: запрос короткий (не больше двух слов) или лучшая близость ниже порога.

KEYWORD_SIM_THRESHOLD = 0.35   # если лучшая близость ниже — включаем фоллбэк
KEYWORD_SHORT_QUERY = 2        # запрос из ≤2 слов — тоже включаем фоллбэк
KEYWORD_WEIGHT = 0.15          # вклад одного совпавшего слова в итоговый рейтинг

q_words = _query_words(query)
use_kw = bool(q_words) and (
    len(q_words) <= KEYWORD_SHORT_QUERY or float(sims.max()) < KEYWORD_SIM_THRESHOLD
)
kw_scores = np.zeros(len(sims), dtype=np.float32)
if use_kw:
    for i, meta in enumerate(self.chunk_meta):
        kw_scores[i] = _keyword_score(meta["text"], q_words)

combined = sims + KEYWORD_WEIGHT * kw_scores
order = np.argsort(-combined)[:limit]

Оценка по словам считает, сколько слов запроса нашлось во фрагменте: точное слово даёт 1,0, а вхождение подстрокой (чтобы цеплялись формы слова) только 0,5:

def _keyword_score(text: str, q_words: list[str]) -> float:
    text_low = text.lower()
    text_words = set(_WORD_RE.findall(text_low))
    score = 0.0
    for w in q_words:
        if w in text_words:
            score += 1.0
        elif len(w) >= 4 and w in text_low:
            score += 0.5
    return score

Подстрочное совпадение разрешено только для слов от четырёх букв, иначе короткое слово находилось бы внутри десятков чужих. Числа из запроса сохраняются: в _query_words отбрасываются слова короче двух символов, кроме цифр. Итоговый рейтинг складывается из чистой близости и взвешенной оценки по словам, но в выдачу я отдаю только чистую близость, score: по ней в ответе видно, насколько фрагмент совпал по смыслу и стоит ли ему верить.

Пороги 0,35 и вес 0,15 я проверял только на нескольких заметках. На большой коллекции их, скорее всего, придётся пересматривать.

На тестовых заметках всё вело себя ожидаемо: запрос «продажи» поднимал на первое место заметку про продажи, «роадмап» заметку про продукт, а имя компании из заметки и число «200» выходили на первое место точно благодаря фоллбэку, без которого эти два запроса были бы лотереей.

Сервер: три инструмента на FastMCP

Сервер собран на FastMCP из официального SDK. Индекс один на весь процесс, модель в память грузится лениво при первом обращении и дальше переиспользуется:

from mcp.server.fastmcp import FastMCP
from mcp.types import ToolAnnotations
import search_index as engine

mcp = FastMCP("notes")
_index = engine.SearchIndex()

_READONLY = ToolAnnotations(
    read_only_hint=True,
    destructive_hint=False,
    idempotent_hint=True,
    open_world_hint=False,
)

Аннотации сообщают клиенту, что инструмент ничего не меняет, повторный вызов безопасен и в «большой мир» он не ходит. Я сначала пробовал задать их словарём с ключами в camelCase, как показано в одном из руководств, но в актуальной версии SDK корректнее объект ToolAnnotations с полями в snake_case, который к тому же проверяется типами.

Сам поисковый инструмент получился коротким. Описание параметров лежит в Field, и именно оно попадает в схему инструмента, которую читает модель:

@mcp.tool(title="Поиск по заметкам", annotations=_READONLY)
def search_notes(
    query: Annotated[str, Field(description="Что ищем — тема или вопрос (рус./англ.).")],
    limit: Annotated[int, Field(ge=1, le=50, description="Сколько фрагментов вернуть (1–50).")] = engine.DEFAULT_LIMIT,
) -> str:
    """Семантический поиск по заметкам. Возвращает самые подходящие фрагменты
    с указанием файла-источника и оценкой близости. При коротком запросе или слабой
    близости дополняется поиском по словам. Новые и изменённые заметки
    подхватываются автоматически."""
    query = (query or "").strip()
    if not query:
        return 'Пустой запрос. Пример: search_notes("условия по оплате").'
    _index.build()  # переиндексирует только изменённые файлы
    results = _index.search(query, limit=limit)
    if not results:
        return f"По запросу «{query}» ничего не найдено. Список заметок — через list_notes()."
    return _format_search_results(query, results)

Автообновление держится на вызове _index.build() перед каждым поиском: он проходит по файлам, сверяет даты и, если ничего не менялось, почти ничего не делает, потому что считать заново нечего.

Отвечает инструмент обычным текстом в markdown, и для каждого фрагмента я выдаю имя файла, номер фрагмента, близость и метод:

out.append(
    f"{rank}. **{r['rel_path']}** · фрагмент {r['chunk_index']} · "
    f"близость {r['score']} ({method})"
)
out.append(f"   {r['text']}")

Все сообщения об ошибках написаны так, чтобы модель могла сама исправиться: пустой запрос подсказывает пример вызова, пустая выдача отсылает к list_notes().

Грабля вторая: любой лишний вывод в stdout ломает протокол

Сервер говорит с клиентом через стандартный вывод, и каждая лишняя строка, которую что-то напечатает в stdout, портит обмен сообщениями. Библиотеки машинного обучения любят печатать предупреждения и прогресс-бары. Поэтому в движке отключён прогресс-бар (show_progress_bar=False), логирование идёт в stderr, а у токенайзера приглушены предупреждения:

os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")

try:
    from transformers.utils import logging as hf_logging
    hf_logging.set_verbosity_error()
except Exception:
    pass

Последний блок убирает предупреждение «длина больше 128». Оно мешало в самотесте, а пользы от него нет: я сам проверяю кандидатов на превышение и дроблю до того, как они попадут в модель. Когда я проверял сервер реальным клиентом по stdio, то отдельно убедился, что stdout остаётся чистым.

Грабля третья: инструмент, который читает файлы по имени от модели

read_note(filename) принимает строку, которую подставила модель, а модель в свою очередь могла получить её из текста заметки или из чьей-то просьбы. Если слепо склеить путь, ../../что-то-важное выведет сервер за пределы папки. Поэтому путь нормализуется и проверяется на принадлежность корню:

def _is_within(path: Path, root: Path) -> bool:
    try:
        path.relative_to(root)
        return True
    except ValueError:
        return False

...
notes_root = engine.NOTES_DIR.resolve()
try:
    target = (engine.NOTES_DIR / filename).resolve()
except Exception:
    return f"Некорректное имя файла: {filename!r}."

if not _is_within(target, notes_root):
    return "Доступ только к файлам внутри папки заметок — попытка выйти за её пределы отклонена."
if not target.exists() or not target.is_file():
    return _not_found_hint(filename)
if target.suffix.lower() not in engine.NOTE_EXTENSIONS:
    return f"Это не заметка (.md/.txt): {filename}."

resolve() раскрывает .. и символические ссылки до настоящего пути, и только после этого проверяется, лежит ли он внутри корня. Проверка по расширению нужна отдельно: даже внутри папки читаются только .md и .txt.

Подсказка при ошибке тоже сделана с расчётом на модель. Если имя неоднозначно (несколько файлов с одинаковым именем в разных подпапках), в ответе перечисляются все совпадения и просьба указать относительный путь. Если файла нет совсем, показывается список доступных заметок, и модель с первого раза подбирает правильное имя:

def _not_found_hint(filename: str) -> str:
    names = [rel for rel, _abs, _mtime in engine._scan_notes()]
    base = Path(filename).name.lower()
    same_name = [n for n in names if Path(n).name.lower() == base]
    if len(same_name) > 1:
        listing = "\n".join(f"- {n}" for n in same_name)
        return f"Имя «{filename}» неоднозначно — несколько файлов с таким именем:\n{listing}\n..."
    listing = "\n".join(f"- {n}" for n in names) or "(заметок нет)"
    return f"Заметка «{filename}» не найдена. Доступные заметки:\n{listing}"

В самотесте сервера (python server.py --selftest) я проверяю оба случая: попытку прочитать файл за пределами папки заметок через ../ и обращение к несуществующему файлу. Первая отклоняется, вторая возвращает список.

Пути: почему сервер не зависит от текущей папки

Claude Code запускает сервер из какой-то своей рабочей папки, и если искать notes/ относительно неё, то в другом проекте сервер просто не найдёт заметок. Поэтому корень вычисляется от самого файла:

def _resolve_base_dir() -> Path:
    env = os.environ.get("CLAUDE_PROJECT_DIR")
    if env:
        candidate = Path(env).expanduser().resolve()
        if (candidate / "notes").exists():
            return candidate
    return Path(__file__).resolve().parent

BASE_DIR = _resolve_base_dir()
NOTES_DIR = BASE_DIR / "notes"
INDEX_DIR = BASE_DIR / ".index"
CACHE_FILE = INDEX_DIR / "cache.pkl"

Переменную окружения я учитываю только если в указанной папке действительно есть notes/, иначе откатываюсь к расположению файла, и случайная переменная от другого проекта сервер не собьёт.

Мелочи, которые не должны ронять индекс

Отдельный набор случаев у любой пользовательской папки: пустой файл, файл в другой кодировке, бинарник с расширением .txt. Движок такие файлы пропускает с предупреждением в лог:

def _read_text(abs_path: Path, rel: str) -> str | None:
    try:
        return abs_path.read_text(encoding="utf-8")
    except UnicodeDecodeError:
        logger.warning("Файл не в UTF-8 (или бинарный) — пропускаю: %s", rel)
        return None
    except Exception as exc:
        logger.warning("Не смог прочитать %s (%s) — пропускаю", rel, exc)
        return None

На пустой папке поиск возвращает понятное сообщение, что заметок нет. Вложенные подпапки внутри notes/ поддерживаются, список файлов собирается через rglob.

Подключение к Claude Code

Регистрация одной командой. Путь к проекту подставляете свой:

claude mcp add notes -- uv run --directory <папка проекта> python server.py
claude mcp list
# notes: ... - ✓ Connected

✓ Connected значит, что Claude Code сам запустил сервер этой командой и прошёл рукопожатие по протоколу, то есть команда рабочая. Есть нюанс, который меня один раз сбил: новый MCP-сервер виден только в новой сессии. Если добавить его посреди работы, инструментов в текущем чате не будет, нужно открыть новый.

Вживую я проверял так: в новом чате попросил найти материал про найм. Claude Code вызвал search_notes, получил пять фрагментов, и на первом месте оказалась заметка про собеседование с близостью 0,5735. После этого он собрал по фрагментам краткую сводку про кандидата.

Для проверки без клиента у меня есть два самотеста: python search_index.py "запрос" печатает топ-5 фрагментов вместе со статистикой нарезки, а python server.py --selftest вызывает все три инструмента напрямую.

Что осталось неидеальным

  • Заметок у меня пока всего четыре, и тестовых. Пороги фоллбэка (0,35 и вес 0,15) проверены только на них, на настоящей большой коллекции поведение может отличаться. Сказать, как поиск поведёт себя на тысяче заметок, я не могу, проверять не пробовал.

  • Кэш хранится в pickle. Для файла, который создаёт и читает одна и та же программа на своей машине, это допустимо, но чужой файл из такого кэша открывать нельзя. Если кто-то подложит вам свой cache.pkl, при загрузке выполнится чужой код.

  • Устаревание файла я определяю по времени изменения. Хэш содержимого надёжнее, но дороже, и я его не добавлял.

  • Ключевой поиск проходит по всем фрагментам циклом на Python. На сотнях фрагментов незаметно, на сотнях тысяч стоило бы переделать.

  • MiniLM я взял как компромисс по размеру: она хуже улавливает тонкие различия в смысле, чем большие модели, и длинные мысли, растянутые на несколько абзацев, теряют связность из-за нарезки на 100-токенные куски.

  • Читаются только .md и .txt в UTF-8. PDF и документы Word движок не открывает, их нужно предварительно переводить в текст.

  • Сервер работает только локально через stdio. Удалённого доступа и веб-интерфейса нет, я их сознательно оставил за рамками.

Итог в цифрах

  • Лимит модели 128 токенов, потолок на фрагмент 124. Реальные русские фрагменты получились от 323 до 492 символов и от 88 до 123 токенов, то есть ничего не обрезается.

  • На трёх первых заметках индекс дал 12 фрагментов. При добавлении четвёртой заметки пересчитался один файл из четырёх, при повторном запуске без изменений ни одного.

  • Три зависимости с жёстко зафиксированными версиями, два файла кода, модель около 120 МБ, без интернета после первой загрузки.

  • Три грабли: модель молча обрезает вход сверх 128 токенов, любой лишний вывод в stdout ломает MCP-обмен, а путь, пришедший от модели, нужно проверять на принадлежность папке заметок.

Если вам нужен просто поиск, ядро сводится к нарезке с проверкой по токенам, кэшу по времени изменения и умножению матрицы на вектор, а обёртка на FastMCP занимает меньше двухсот строк.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.