Когда вы вводите запрос в поисковую систему, что-то должно решить, какие документы действительно релевантны — и как их ранжировать. Алгоритм BM25 (Best Matching 25), лежащий в основе таких поисковых систем, как Elasticsearch и Lucene, десятилетиями был доминирующим ответом на этот вопрос.
Как работает BM25
BM25 присваивает релевантность каждому документу в коллекции по заданному запросу, а затем ранжирует документы по этой релевантности. Для каждого термина в вашем запросе BM25 задаёт три вопроса:
* Как часто этот термин появляется в документе?
* Насколько редок этот термин во всей коллекции?
* Является ли этот документ необычно длинным?
Окончательная оценка — это сумма взвешенных ответов на эти вопросы по всем терминам запроса.
Отличие BM25 от векторного поиска
BM25 и векторный поиск отвечают на один и тот же вопрос — какие документы релевантны этому запросу? — но через принципиально разные подходы. BM25 — это алгоритм сопоставления ключевых слов: он ищет точные слова из вашего запроса внутри каждого документа, оценивает их на основе частоты и редкости и ранжирует соответственно. У него нет понимания языка — он видит текст как набор токенов, а не как смысл.
Векторный поиск, напротив, преобразует и запрос, и каждый документ в плотные числовые векторы с помощью модели внедрения, а затем находит документы, чьи векторы указывают в том же направлении, что и вектор запроса — измеренный косинусным сходством. Это означает, что векторный поиск может сопоставить «остановка сердца» с документом о «сердечной недостаточности», даже если ни одно из слов не совпадает, потому что модель внедрения научилась тому, что эти понятия находятся близко друг к другу в семантическом пространстве.
Сравнение BM25 и векторного поиска в Python
Установка зависимостей
«`
pip install rank_bm25 openai numpy
«`
Определение корпуса
Перед сравнением BM25 и векторного поиска нам нужна общая база знаний для поиска. Мы определяем 12 коротких текстовых фрагментов, охватывающих ряд тем — Python, машинное обучение, BM25, трансформеры, внедрения, RAG, базы данных и многое другое. Темы намеренно разнообразны: некоторые фрагменты тесно связаны (BM25 и TF-IDF, внедрения и косинусное сходство), а другие совершенно не связаны (PostgreSQL, Django).
Создание индекса BM25
С определённым корпусом мы можем создать индекс BM25. Процесс состоит из двух шагов: токенизации и индексации. Функция tokenize преобразует текст в нижний регистр и разбивает его на любые нечисловые символы — так «TF-IDF» становится [“tf”, “idf”], а «bag-of-words» становится [“bag”, “of”, “words”].
Построение индекса BM25
«`
def tokenize(text: str) -> list[str]:
«»»Lowercase and split on non-alphanumeric characters.»»»
return re.findall(r’\w+’, text.lower())
Build BM25 index over the corpus
tokenized_corpus = [tokenize(chunk) for chunk in CHUNKS]
bm25 = BM25Okapi(tokenized_corpus)
«`
Создание поискового запроса BM25
«`
def bm25search(query: str, topk: int = 3) -> list[dict]:
«»»Return top-k chunks ranked by BM25 score.»»»
tokens = tokenize(query)
scores = bm25.get_scores(tokens)
ranked = np.argsort(scores)[::-1][:top_k]
return [
{«chunk_id»: int(i), «score»: round(float(scores[i]), 4), «text»: CHUNKS[i]}
for i in ranked
]
«`
Создание поискового запроса с внедрением
Встраиваемый поисковый запрос работает иначе на каждом этапе. Вместо подсчёта токенов он преобразует каждый фрагмент в плотный числовой вектор — список из 1536 чисел — с помощью модели встраивания текста OpenAI. Каждый номер представляет собой измерение в семантическом пространстве, и фрагменты, которые означают похожие вещи, в конечном итоге получают векторы, которые указывают в похожих направлениях, независимо от используемых слов.
Построение индекса внедрения
«`
EMBED_MODEL = «text-embedding-3-small»
def get_embedding(text: str) -> np.ndarray:
response = client.embeddings.create(model=EMBED_MODEL, input=text)
return np.array(response.data[0].embedding)
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
Embed all chunks once (this is the «index build» step in RAG)
print(«Building embedding index… (one API call per chunk)»)
chunkembeddings = [getembedding(chunk) for chunk in CHUNKS]
print(f»Done. Each embedding has {len(chunk_embeddings[0])} dimensions.»)
«`
Поиск по встраиванию
«`
def embeddingsearch(query: str, topk: int = 3) -> list[dict]:
«»»Return top-k chunks ranked by cosine similarity to the query embedding.»»»
queryemb = getembedding(query)
scores = [cosinesimilarity(queryemb, emb) for emb in chunk_embeddings]
ranked = np.argsort(scores)[::-1][:top_k]
return [
{«chunk_id»: int(i), «score»: round(float(scores[i]), 4), «text»: CHUNKS[i]}
for i in ranked
]
«`
Функция сравнения
Это ядро эксперимента. Функция compare запускает один и тот же запрос через оба поисковых механизма одновременно и выводит результаты в двухстолбцовом макете — BM25 слева, встраивание RAG справа — так что различия сразу видны на одной и той же позиции ранга.
«`
def compare(query: str, top_k: int = 3):
bm25results = bm25search(query, top_k)
embedresults = embeddingsearch(query, top_k)
print(f»\n{‘═’*70}»)
print(f» QUERY: \»{query}\»»)
print(f»{‘═’*70}»)
print(f»\n {‘BM25 (keyword)’:<35} {'Embedding RAG (semantic)'}")
print(f» {‘─’33} {‘─’33}»)
for rank, (b, e) in enumerate(zip(bm25results, embedresults), 1):
b_preview = b[‘text’][:55].replace(‘\n’, ‘ ‘)
e_preview = e[‘text’][:55].replace(‘\n’, ‘ ‘)
same = » same» if b[‘chunkid’] == e[‘chunkid’] else «»
print(f» #{rank} [{b[‘chunkid’]:02d}] {b[‘score’]:.4f} {bpreview}…»)
print(f» [{e[‘chunkid’]:02d}] {e[‘score’]:.4f} {epreview}… {same}»)
print()
«`
1. Какие основные отличия в подходах к извлечению информации у алгоритмов BM25 и векторного поиска?
Ответ: BM25 использует алгоритм сопоставления ключевых слов, оценивая частоту и редкость терминов в документах. Векторный поиск преобразует запрос и документы в числовые векторы и находит документы с векторами, близкими к вектору запроса.
2. Как BM25 определяет релевантность документов?
Ответ: BM25 присваивает релевантность каждому документу на основе частоты появления терминов в документе, редкости этих терминов во всей коллекции и длины документа.
3. Почему векторный поиск может сопоставить «остановка сердца» с документом о «сердечной недостаточности», даже если ни одно из слов не совпадает?
Ответ: Векторный поиск использует модель внедрения, которая преобразует текст в числовые векторы. Эти векторы представляют собой измерения в семантическом пространстве, и фрагменты, которые означают похожие вещи, в конечном итоге получают векторы, которые указывают в похожих направлениях, независимо от используемых слов.
4. Какие шаги включает в себя создание индекса BM25?
Ответ: Создание индекса BM25 включает в себя токенизацию (преобразование текста в нижний регистр и разбиение его на любые нечисловые символы) и индексацию (создание структуры данных для быстрого поиска).
5. Как работает поиск по внедрению в векторном поиске?
Ответ: Поиск по внедрению в векторном поиске включает в себя преобразование каждого фрагмента в плотный числовой вектор с помощью модели встраивания текста OpenAI. Затем эти векторы сравниваются с вектором запроса, и документы с наиболее похожими векторами возвращаются в качестве результатов поиска.