Был у меня 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 рядом, написал скрипт индексации и один эндпоинт.
Сложного тут немного — основное время ушло не на код, а на то, чтобы разобраться какие инструменты вообще брать. Надеюсь, этот разбор сэкономит тебе те три часа, которые я потерял в пятницу вечером.
