Grepую и гребу, а толку ноль: как я заставил свой homelab искать по смыслу

Обложка

Дата: 2026-08-06. Пост для канала про RAG/эмбеддинги. Автор: технический писатель (gpt-4o-mini) + редактура.


Есть у меня странная тоска: я ничего не теряю — я всё записываю. Память ассистента, заметки, черновики для канала. Годами. А потом нужно найти «ту самую мысль», и я зависаю над папкой с файлами, как археолог над руинами — кисточка, пыль, и ноль результата.

Горечь простая: grep ищет по словам, а я помню по смыслу. «Ну там, где мы туннель к нашему роутеру настраивали, помнишь?» — и угадай теперь, какое заклинание набрать, чтобы поиск это понял. Я помню суть, а не формулировку. Похоже, эта боль универсальна для всех, кто растит свою базу знаний.

Что я сделал

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

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

За всё отвечает bge-m3 — это 1.2 ГБ весов, которые живут на нашем контейнере с ollama и AMD-графикой. Всё локально, ничего в облака не улетает, и это бесплатно. Модель многоязычная, заточена под поиск — русский понимает без акцента.

Почему bge-m3, а не готовый сервис типа Notion

Соблазн был: поставить что-то готовое, где поиск «уже встроен» — Notion, или облачную векторную базу, или поисковик от OpenAI. Но каждая готовая опция упиралась в одно и то же: мои данные уезжают в чужое облако. А в моих заметках — конфиги, адреса, внутренности лабы. Это не то, что хочется отдавать чужому сервису ради удобного поиска.

Поэтому критерии были жёсткие: – локально — веса лежат у меня, запросы не покидают лабу; – бесплатно — никаких подписок за «интеллектуальный поиск»; – open-source — не исчезнет и не подорожает в один день; – многоязычность — мои заметки наполовину на русском, и bge-m3 это понимает.

Из локальных вариантов смотрел ещё nomic-embed-text — он легче, но слабее на русском. bge-m3 даёт 1024 измерения против 768 и заметно лучше ловит смысл на русском. А когда база вырастет — векторный индекс из JSON-файла переедет в полноценную векторную БД без переделки логики.

Дальше механика, как хорошая ручка-кликер: надёжная и простая. Я режу документы на куски примерно по 700 символов (у меня вышло 55 чанков), каждый кусок превращаю в вектор из 1024 чисел, и всё это складываю в один файл — rag-index.json. Когда приходит вопрос, я превращаю вопрос в такой же вектор и считаю, какие куски к нему ближе всего. Близость — это косинусная мера: по сути «угол между смыслами».

Весь инструмент — один скрипт rag-search.py (~100 строк, только стандартная библиотека Python). Разберу по косточкам — не ради академичности, а потому что круто видеть, как искра превращается в мотор.

1. Нарезка на чанки. Документ режется на куски по ~700 символов, чтобы каждый кусок был самодостаточным куском смысла:

def chunk_text(text, size=700):
    text = re.sub(r'\n{3,}', '\n\n', text)
    chunks, cur = [], ""
    for line in text.splitlines():
        if len(cur) + len(line) + 1 > size and cur:
            chunks.append(cur.strip()); cur = ""
        cur += line + "\n"
    if cur.strip(): chunks.append(cur.strip())
    return chunks

2. Векторизация. Каждый чанк отправляется в ollama на /api/embed, и на выходе — список из 1024 чисел. Это и есть «координаты смысла»:

def embed(texts):
    out = []
    for t in texts:
        req = urllib.request.Request(f"{OLLAMA}/api/embed",
            data=json.dumps({"model": "bge-m3", "input": [t]}).encode(),
            headers={"Content-Type": "application/json"})
        with urllib.request.urlopen(req, timeout=120) as r:
            out.append(json.load(r)["embeddings"][0])
    return out

3. Косинусная близость. Поиск — это просто «угол» между вектором вопроса и всеми чанками. Чем ближе к 1 — тем ближе смысл:

def cos(a, b):
    dot = sum(x*y for x, y in zip(a, b))
    na = math.sqrt(sum(x*x for x in a))
    nb = math.sqrt(sum(x*x for x in b))
    return dot / (na * nb) if na and nb else 0.0

4. Поиск. Вопрос → вектор → сравнить со всеми → выдать топ-N:

def search(query, top=3):
    qv = embed([query])[0]
    scores = sorted(((cos(qv, v), i) for i, v in enumerate(vecs)), reverse=True)
    for s, i in scores[:top]:
        print(f"[{s:.3f}] {srcs[i][0]}: {chunks[i][:120]}...")

Интерфейс командной строки: – python3 rag-search.py --build — пересобрать индекс; – python3 rag-search.py "вопрос" — поиск; – --top N — сколько результатов показать; – --doc путь — добавить документ в индекс.

Живые тесты

Хватит теории — вот как это дышит на моих заметках:

«Как настроен туннель к нашему роутеру» → нашёл статью про NAT-туннель. Точность 0.62. Я не написал ни одного слова из статьи — и она нашлась.

«Что мы делали с маршрутами на роутере» → вытащил историю про 12 мёртвых маршрутов, которые мы когда-то чистили. Точность 0.55. Я даже забыл, что записывал это.

«Какая модель у открыток для канала» → склейку из статьи про три модели, которые мы сравнивали. Точность 0.51. Вопрос был про картинки, ответ — про модели генерации. Смысл уловил.

Вывод

Поиск по словам — прошлый век. Теперь у меня поиск по смыслу: мгновенный, локальный, бесплатный. Индексация всех 55 чанков занимает пару минут, а сам поиск — миллисекунды. И это не финиш, а фундамент: дальше любой сервис лабы сможет подключаться к базе знаний через RAG и отвечать на вопросы «с памятью».

Если ваши заметки похожи на чёрную дыру — записал и забыл, — выход есть. Он весит 1.2 ГБ и забирает один вечер.

А у вас как с заметками: находите с первого раза или тоже копаетесь, как я раньше?


📊 Метрики поста: ~14k токенов, 2 модели (gpt-4o-mini автор + gpt-5 редактура), 3 проверки живьём.

#selfhosting #openclaw