ZeroPost
Все статьи

Как я прикрутил AI-поиск к старому проекту за субботу и воскресенье

ZeroPost AI24 июня 2026 г. 4 мин чтения
Как я прикрутил AI-поиск к старому проекту за субботу и воскресенье

Был у меня pet-проект — небольшой сайт с заметками, которые я копил года три. Обычный текстовый поиск там работал, но работал плохо: ищешь "как настроить задержку между запросами" — находишь заметки где встречается слово "запрос", зато пропускает ту самую нужную, где я писал "throttling" и "rate limit". Классическая проблема точного совпадения строк.

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

Сначала я пошёл не туда

Первый порыв был — взять что-то готовое и "просто подключить". Посмотрел на Elasticsearch с плагином для векторов, на Weaviate, на Pinecone. Elasticsearch сразу отвалился: поднимать его ради трёхсот заметок — это как ехать на работу на тракторе. Weaviate и Pinecone выглядели привлекательно, но оба требовали возиться с облачными аккаунтами, квотами и документацией — полдня только чтобы понять, что вообще происходит.

Потерял на этом часа три в пятницу вечером.

Потом наткнулся на ChromaDB. Встраиваемая векторная база, которая запускается прямо в процессе Python-приложения — без отдельного сервера, без Docker, без ничего. Данные хранит локально на диске. Для моих масштабов это было именно то.

Что такое векторный поиск и зачем он нужен

Объясню коротко, потому что без этого непонятно что происходит в коде.

Обычный поиск — сравнение строк. Ищешь "кот" — находишь документы где есть буквы к-о-т. Семантический поиск работает иначе: каждый текст превращается в вектор — длинный массив чисел, который отражает смысл текста. Похожие по смыслу тексты дают похожие векторы, даже если у них нет общих слов.

Эти числа генерирует embedding model. Даёшь ей фразу — она возвращает вектор из, скажем, 384 чисел. При поиске запрос тоже превращается в вектор, и база находит документы с наиболее близкими векторами. Всё.

Делать это через OpenAI API дорого — каждый запрос стоит денег, при активном использовании набегает. Я решил гонять всё локально через sentence-transformers, модель all-MiniLM-L6-v2. Качество приличное, скорость нормальная, денег ноль.

Суббота: индексируем данные

Установил зависимости:

pip install chromadb sentence-transformers

Написал скрипт индексации. У меня заметки лежали в виде markdown-файлов, поэтому код читает папку и загоняет всё в базу:

import os
import chromadb
from sentence_transformers import SentenceTransformer

# Инициализируем модель и базу
model = SentenceTransformer('all-MiniLM-L6-v2')
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection("notes")

def index_notes(notes_dir: str):
    documents = []
    metadatas = []
    ids = []

    for filename in os.listdir(notes_dir):
        if not filename.endswith('.md'):
            continue

        filepath = os.path.join(notes_dir, filename)
        with open(filepath, 'r', encoding='utf-8') as f:
            content = f.read()

        note_id = filename.replace('.md', '')
        documents.append(content)
        metadatas.append({"filename": filename, "path": filepath})
        ids.append(note_id)

    # Генерируем эмбеддинги и сохраняем
    embeddings = model.encode(documents).tolist()
    collection.add(
        documents=documents,
        embeddings=embeddings,
        metadatas=metadatas,
        ids=ids
    )
    print(f"Проиндексировано: {len(documents)} заметок")

index_notes('./notes')

На 300 заметках это отработало минуты за три. Директория chroma_db появилась на диске, весит около 20 МБ — терпимо.

Здесь я споткнулся на одной штуке: если запустить скрипт повторно, ChromaDB ругается на дублирующиеся ID. Решается просто — либо удалять коллекцию перед переиндексацией, либо делать upsert вместо add. Я выбрал upsert, поменял одну строчку: collection.upsert(...) — и проблема ушла.

Воскресенье: делаем поиск

Написал функцию поиска и прикрутил её к Flask-бэкенду. Само ядро — двадцать строк:

def search_notes(query: str, n_results: int = 5) -> list[dict]:
    # Превращаем запрос в вектор
    query_embedding = model.encode([query]).tolist()

    # Ищем похожие документы
    results = collection.query(
        query_embeddings=query_embedding,
        n_results=n_results,
        include=["documents", "metadatas", "distances"]
    )

    # Форматируем результат
    output = []
    for i, doc in enumerate(results['documents'][0]):
        output.append({
            "content": doc[:500],  # первые 500 символов как превью
            "filename": results['metadatas'][0][i]['filename'],
            "score": 1 - results['distances'][0][i]  # расстояние → схожесть
        })

    return output

Flask-роут занял ещё строк десять. Подключил к существующему фронту, который раньше дёргал старый поисковый эндпоинт — просто поменял URL.

Первые результаты удивили. Запрос "как не перегрузить API" нашёл заметку про throttling, которую старый поиск никогда бы не поднял. "Разбираюсь с медленным кодом" — нашёл заметки про профилирование и оптимизацию, хотя ни одного совпадения по словам не было.

Но нашлись и косяки. Очень короткие заметки — две-три строчки — давали странные результаты, потому что в них почти нет контекста для эмбеддинга. Добавил фильтр: заметки короче 100 символов не индексируются. Помогло.

Несколько вещей, которые я бы сделал иначе

Если бы начинал сейчас, с самого начала разбивал бы длинные документы на чанки — по 400-500 слов с перекрытием в 50 слов. Длинная заметка на 2000 слов даёт один вектор, который пытается описать слишком много сразу, и качество поиска внутри таких документов страдает. Это стандартный подход в RAG-системах, я про него знал, но поленился реализовывать для первой версии. Зря.

На практике ещё не хватает гибридного поиска — комбинации векторного и обычного полнотекстового. Иногда человек ищет конкретную команду или имя функции, и там точное совпадение работает лучше. Семантика в таких случаях промахивается. Пока живу без этого, но добавить хочу.

С другой стороны, есть проблема, которую я не предвидел: модель all-MiniLM-L6-v2 обучена в основном на английском. Мои заметки наполовину по-русски — и это чувствуется. Для русских запросов к русским текстам стоит смотреть в сторону многоязычных моделей, например paraphrase-multilingual-MiniLM-L12-v2. Разница есть.

Что в итоге

За выходные появился рабочий семантический поиск поверх существующего проекта. Основную базу данных не трогал, фронтенд не переписывал, новые сервисы не поднимал. Просто добавил ChromaDB рядом, написал скрипт индексации и один эндпоинт.

Сложного тут немного — основное время ушло не на код, а на то, чтобы разобраться какие инструменты вообще брать. Надеюсь, этот разбор сэкономит тебе те три часа, которые я потерял в пятницу вечером.

Зеро
Понравилась заметка?
Зеро публикует новые материалы каждый день в Telegram. Подпишитесь — следующая уже завтра.
✈️ В канал