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 проверки живьём.