Полнотекстовый поиск в Python с whoosh3: замена LIKE и Elasticsearch. Настройка схемы, ранжирование BM25, интеграция с FastAPI. Попробуйте прямо сейчас!
Каждое приложение рано или поздно нуждается в поиске, который работает лучше, чем WHERE title LIKE '%...%'. LIKE не умеет ранжировать результаты, не понимает, что running должно соответствовать run, и тормозит, как только ваша таблица становится интересной. Обычный следующий шаг — поднять Elasticsearch или OpenSearch — означает JVM, кластер, за которым нужно ухаживать, и целое второе хранилище данных, которое нужно синхронизировать. Для множества приложений это как использовать кран, чтобы повесить картину. whoosh3 — это чисто Python-библиотека полнотекстового поиска (поддерживаемое продолжение Whoosh). pip install whoosh3 — и вы получаете ранжирование BM25, стемминг, настоящий язык запросов и индекс, который является просто папкой на диске — работающий в вашем процессе, без сервера. Вот как встроить его в веб-приложение, включая операционные моменты, на которых люди спотыкаются.
from whoosh.fields import Schema, TEXT, ID, NUMERIC
from whoosh.analysis import StemmingAnalyzer
schema = Schema(
id=ID(stored=True, unique=True),
title=TEXT(analyzer=StemmingAnalyzer(), stored=True, field_boost=2.0),
body=TEXT(analyzer=StemmingAnalyzer(), stored=True),
views=NUMERIC(stored=True, sortable=True),
)Здесь три выбора делают реальную работу. unique=True на id делает индекс апсертируемым — запись того же id дважды заменяет строку, а не дублирует её. StemmingAnalyzer заставляет widget соответствовать widgets. field_boost=2.0 означает, что совпадение в заголовке считается вдвое больше, чем в теле, поэтому нужный результат всплывает наверх без ручного скоринга.
Создайте индекс один раз (он живёт в директории), затем всё — это апсерт, удаление и поиск:
from whoosh import index
from whoosh.qparser import MultifieldParser, OrGroup
from whoosh.writing import AsyncWriter
ix = index.create_in("indexdir", schema)
# или index.open_dir("indexdir")
def upsert(doc: dict):
w = AsyncWriter(ix)
w.update_document(**doc) # вставка-или-замена по уникальному id
w.commit()
def delete(doc_id: str):
w = AsyncWriter(ix)
w.delete_by_term("id", doc_id)
w.commit()
def search(q: str, k: int = 5):
with ix.searcher() as s:
parser = MultifieldParser(["title", "body"], ix.schema, group=OrGroup)
hits = s.search(parser.parse(q), limit=k)
return [(h["id"], h["title"], round(h.score, 3)) for h in hits]update_document — это вся история синхронизации: когда строка в вашей реальной базе данных меняется, вызывайте upsert с тем же id, и индекс догоняет. Посмотрите, что это делает с ранжированием:
upsert({"id": "1", "title": "Getting started with widgets", "body": "install and configure your first widget", "views": 10})
upsert({"id": "2", "title": "Widget troubleshooting", "body": "fix common widget errors and crashes", "views": 99})
search("widget")
# [('2', 'Widget troubleshooting', 1.435), ('1', 'Getting started with widgets', 1.397)]
upsert({"id": "1", "title": "Getting started with gadgets", "body": "install your first gadget", "views": 10})
# документ 1 отредактирован на месте
search("widget")
# [('2', 'Widget troubleshooting', 2.386)] <- документ 1 выпал, оценка скорректирована
search("gadget")
# [('1', 'Getting started with gadgets', 3.432)]MultifieldParser ищет по заголовку и телу вместе; OrGroup позволяет многословному запросу соответствовать любому из терминов (дружелюбно к полноте), в то время как BM25 всё равно ранжирует ближайшие совпадения первыми.
Вот где наивная интеграция ломается под нагрузкой. Индекс Whoosh допускает ровно одного писателя за раз — откройте второй ix.writer(), пока другой не закоммичен, и получите LockError. В веб-приложении с конкурентными запросами это случится. Два надёжных паттерна:
AsyncWriter (использован выше): если индекс заблокирован, он повторяет попытку в фоновом потоке, а не выбрасывает исключение. Отлично для низкой и умеренной частоты записей — комментарии, правки, редкие админские изменения.commit() имеет фиксированные накладные расходы, а батчинг их амортизирует.У чтения нет такого ограничения: открывайте свежий ix.searcher() на каждый запрос (они дёшевы и видят последнее закоммиченное состояние) и никогда не держите его открытым между запросами. Изменения становятся видимыми для новых читателей, как только commit() возвращается — почти реальное время, без настройки интервала обновления.
Эндпоинты — тонкая оболочка над этими тремя функциями:
from fastapi import FastAPI
app = FastAPI()
@app.put("/docs/{doc_id}")
def put(doc_id: str, doc: dict):
upsert({"id": doc_id, **doc}); return {"ok": True}
@app.delete("/docs/{doc_id}")
def remove(doc_id: str):
delete(doc_id); return {"ok": True}
@app.get("/search")
def query(q: str, k: int = 10):
return [{"id": i, "title": t, "score": s} for i, t, s in search(q, k)]Это поисковый API продакшн-уровня — ранжированный, со стеммингом, инкрементально обновляемый — без отдельного сервиса для развёртывания, а индекс можно бэкапить простым копированием папки. Полные запускаемые версии для FastAPI, Flask и Django находятся в каталоге examples/ репозитория.
Когда стоит всё же тянуться к Elasticsearch? Когда вы действительно переросли одноузловой внутрипроцессный индекс — огромные корпуса, распределённое шардирование, кластерная аналитика. До тех пор встроенный движок — это меньше работы, меньше риска поломок и более чем достаточно. Начните с pip install whoosh3, создайте схему, как показано выше, и замените LIKE-запросы на ранжированный поиск уже сегодня.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →