Содержание курса 33 / 37

33 / 37 · Современные LLM-системы

Эмбеддинги и семантический поиск

Как найти среди тысяч фрагментов те, что отвечают на запрос: лексическое сопоставление с BM25, плотный поиск на контрастивно обученных эмбеддингах предложений и эмбеддингах, управляемых инструкцией, постоянные индексы, нарезка документов на фрагменты, переранжирование кросс-энкодером, гибридный поиск и фильтры по метаданным, написанные языковой моделью, а также метрики (доля попаданий в топ-k, средний обратный ранг), которыми поисковую модель измеряют на эталонной выборке до всякой генерации.

На этой странице 12

Заметка об эмбеддингах закончилась поиском ближайших соседей по усреднённым векторам слов: закодировать вопрос, сравнить его по косинусу с каждым сохранённым вопросом, вернуть самый близкий. В этом и состоит идея семантического поиска; с тех пор изменились энкодер, индекс и инженерия вокруг того, что именно кодировать. В этой заметке строится поисковая модель (retriever) по корпусу русскоязычных постов о машинном обучении, задаётся способ её измерить и описываются три варианта, которые с ней нужно сравнить. Оценок поиска на этом корпусе заметка не приводит: варианты — это то, что стоит попробовать и измерить процедурой ниже, а не измеренные улучшения. Генерация появится только в следующей заметке: сначала поисковая модель должна работать правильно сама по себе.

Обозначения. Запрос qq и корпус фрагментов d1,…,dNd_1, \dots, d_N; модель эмбеддингов e(⋅)∈Rme(\cdot) \in \mathbb R^{m}; сходство sim(q,d)\mathrm{sim}(q, d); поисковая модель возвращает kk фрагментов с наибольшим сходством. Для запроса с известным релевантным фрагментом rank(q)\mathrm{rank}(q) — позиция этого фрагмента в возвращённом списке (∞\infty, если его там нет).

Постановка задачи#

Вход: запрос, обычно короткий. Выход: ранжированный список фрагментов из фиксированного корпуса. Корпус здесь — выгрузка Telegram-канала: 830 постов на русском с английскими техническими терминами, в основном короткие (медиана около 47 слов), 235 длиннее 200 слов, самый длинный — 659. Тестовая выборка — вручную составленная эталонная выборка (golden set) из 20 пар (вопрос, отрывок): отрывок длиной около 60 слов взят из одного поста и содержит ответ, а вопрос сформулирован так, как его задал бы читатель.

Рисунок

Поисковая модель во время запроса

Гибридный поиск с переранжированиемЗапрос идёт двумя путями: через энкодер в плотный поиск по ближайшим векторам и напрямую в поиск BM25 по общим терминам. Два списка сливаются по рангам (взвешенный RRF), кросс-энкодер переранжирует лучших кандидатов, и на выходе остаются k лучших фрагментов.запросэнкодервектор запросаплотный поискближайшие векторыпоиск BM25общие терминыслияниевзвешенный RRFкросс-энкодерпереранжируеттоп-kфрагментовГибридный поиск с переранжированиемЗапрос идёт двумя путями: через энкодер в плотный поиск по ближайшим векторам и напрямую в поиск BM25 по общим терминам. Два списка сливаются по рангам (взвешенный RRF), кросс-энкодер переранжирует лучших кандидатов, и на выходе остаются k лучших фрагментов.запросэнкодервектор запросаплотный поискближайшие векторыпоиск BM25общие терминыслияниевзвешенный RRFкросс-энкодерпереранжируеттоп-k фрагментов
Поисковая модель, к которой ведёт эта заметка, во время запроса. Заранее, один раз, посты режутся на фрагменты, каждый фрагмент кодируется энкодером, а векторы и обратный индекс терминов сохраняются. На запрос энкодер даёт его вектор для плотного поиска по ближайшим векторам, а BM25 параллельно ищет фрагменты с общими терминами: плотный поиск ловит пересказ, BM25 — точные имена и коды. Два списка сливаются по рангам, кросс-энкодер, читающий запрос и фрагмент вместе, переранжирует лучших кандидатов, и дальше передаются k лучших. Части пробуются по отдельности — гибрид и переранжирование поверх поиска по родительским документам, — и каждый вариант измеряется на эталонной выборке до того, как подключается генерация.

Базовый вариант: лексическое сопоставление#

Фрагмент оценивается по терминам запроса, которые в нём есть, с весами как в заметке о TF-IDF. Стандартное уточнение — BM25 (Robertson и Zaragoza, 2009):

BM25(q,d)=∑t∈qidf(t)⋅f(t,d) (k1+1)f(t,d)+k1(1−b+b ∣d∣∣d∣‾),\mathrm{BM25}(q, d) = \sum_{t \in q} \mathrm{idf}(t)\cdot \frac{f(t, d)\,(k_1 + 1)}{f(t, d) + k_1\big(1 - b + b\,\frac{|d|}{\overline{|d|}}\big)},

где f(t,d)f(t, d) — число вхождений термина tt в dd, ∣d∣|d| — длина фрагмента, ∣d∣‾\overline{|d|} — средняя длина, а k1≈1.2k_1 \approx 1.2, b≈0.75b \approx 0.75. В отличие от простого TF-IDF, счётчик насыщается (десятое вхождение термина почти ничего не добавляет), а длинные фрагменты штрафуются. BM25 быстр (обратный индекс), не требует обучения, точен на редких терминах и идентификаторах (Log-Linear Attention, код ошибки, название продукта) и ломается на несовпадении словаря (vocabulary mismatch): запрос об ускорении внимания для длинных последовательностей не делит ни одного термина с фрагментом о субквадратичной сложности.

Плотный поиск#

Модель эмбеддингов#

Биэнкодер (bi-encoder): один энкодер-трансформер применяется отдельно к запросу и к каждому фрагменту, выходы сводятся в один вектор (среднее векторов токенов или вектор [CLS]) и нормируются до единичной длины, так что косинус превращается в скалярное произведение. Он обучается контрастивно на парах (запрос, положительный фрагмент) с негативами из батча (in-batch negatives): для батча из BB пар функция потерь поднимает сходство каждого запроса с его собственным фрагментом выше сходства с остальными B−1B - 1 фрагментами,

L=−1B∑i=1Blog⁡exp⁡(sim(qi,di)/τ)∑j=1Bexp⁡(sim(qi,dj)/τ),\mathcal L = -\frac1B\sum_{i=1}^B \log \frac{\exp(\mathrm{sim}(q_i, d_i)/\tau)}{\sum_{j=1}^B \exp(\mathrm{sim}(q_i, d_j)/\tau)},

это целевая функция InfoNCE (Karpukhin et al., 2020; Reimers и Gurevych, 2019). Замороженный [CLS] маскированной языковой модели — плохой вектор предложения, как показала заметка о переносе обучения; именно контрастивное обучение делает векторы сравнимыми между текстами.

При использовании такой модели важны два соглашения. Симметричная модель кодирует запросы и фрагменты одинаково (BAAI/bge-m3, 1024 измерения, многоязычная). Асимметричная модель ждёт префикс, который помечает сторону текста (intfloat/multilingual-e5-large: query: и passage: ): вопросы и ответы — тексты разного рода, оба читает один и тот же энкодер, и различить их ему позволяет только префикс, с которым он обучался. Без префикса асимметричная модель молча работает хуже.

Модель эмбеддингов, управляемая инструкцией (instruction-conditioned), заменяет фиксированный префикс предложением, которое называет назначение. INSTRUCTOR (Su et al., 2023) читает перед текстом инструкцию вида Represent the (domain) (text type) for (task):. Для вопросов открытой предметной области (open-domain) это Represent the Wikipedia question for retrieving supporting documents: на стороне запроса и Represent the Wikipedia document for retrieval: на стороне фрагментов. Вектор — среднее выходных векторов только токенов текста, но каждый из них через внимание видел инструкцию, так что один и тот же текст получает разные векторы для разных задач. Симметричные задачи, например оценка близости, используют одну инструкцию для обоих текстов; поиск — две. Модель — энкодер на основе T5 с 335 млн параметров — обучалась контрастивно на 330 наборах данных, каждый из которых размечен своей инструкцией. В среднем по девяти категориям задач на 70 наборах данных для оценки, 66 из которых не встречались при обучении, она набирает 58.4 против 55.1 у того же энкодера до обучения на инструкциях и 56.5 у модели эмбеддингов на 4.8 млрд параметров, Sent-T5-XXL. Прирост относительно исходного энкодера выше среднего на трёх предметных областях, отсутствовавших при обучении (география, биология, комментарии в интернете из Civil Comments): инструкции помогают сильнее всего там, где корпус не похож на то, на чём обучался энкодер. Правило для префиксов по-прежнему действует: каждый фрагмент в индексе кодируется с одной и той же инструкцией, а каждый запрос — со своей, и смена инструкции для фрагментов означает перекодирование всего корпуса.

Индекс#

Фрагменты кодируются один раз и хранятся вместе с векторами в векторном хранилище (здесь — коллекция Qdrant в памяти). Запрос стоит одного прохода энкодера и поиска ближайших соседей; для корпусов из миллионов векторов поиск приближённый (графы HNSW, продуктовое квантование (product quantization)), он жертвует небольшой долей полноты ради поиска за доли миллисекунды. При таком размере корпуса подходит точный поиск.

Индекс в памяти исчезает вместе с процессом. Постоянное (persistent) хранилище записывает векторы, тексты и метаданные на диск или держит их на сервере базы данных, так что корпус кодируется один раз, а каждый следующий сеанс открывает сохранённый индекс и кодирует только свои запросы. С этим связаны два правила. Запрашивать индекс можно только тем энкодером, который его построил: векторы разных моделей живут в разных пространствах, часто разной размерности. А изменённый документ нужно заново закодировать и записать под тем же идентификатором, иначе индекс отвечает по старой версии. Чтобы проиндексировать большой корпус, держать его в памяти не нужно: документы можно читать лениво, по одному из файла, и добавлять батчами. Кодирование миллионов документов — дорогой шаг, поэтому прототип индексирует часть корпуса и измеряется на эталонной выборке до полного прогона.

Нарезка на фрагменты#

Энкодер читает несколько сотен токенов, а длинный пост содержит несколько мыслей; эмбеддинг целого поста усредняет их в один вектор, который плохо совпадает с любым из вопросов. Поэтому документы режутся на фрагменты (chunks), и выбор единицы определяет всё дальнейшее:

Способ нарезкиЕдиницаСохраняетТеряетПрименение
Окно фиксированной длины в токенах (256 токенов, перекрытие 64)токеныточный размер, предсказуемую стоимостьграницы предложений и словконвейеры, чувствительные к оплате за токены
По предложениямпредложения до заданного размеразаконченные мыслиодинаковый размер; мысли, растянутые на несколько предложенийтексты с самостоятельными предложениями
Семантическая (соседние предложения сливаются, пока косинус ≥ 0.5)тематически связные группыодна мысль на фрагментпредсказуемый размердокументы на несколько тем
Рекурсивная (заголовки → абзацы → предложения → токены)структура документаиерархиюнужны правила под форматMarkdown, HTML, документация

Второй приём разделяет то, что сопоставляется, и то, что возвращается: поиск по родительским документам (parent-document retrieval) индексирует маленькие дочерние фрагменты (здесь 120 символов) для точного сопоставления, а возвращает родителя, из которого они взяты (здесь весь пост, обрезанный по 4000 символов, если он длиннее), чтобы генератору хватило контекста. Перекрытие фрагментов страхует от ответа, разрезанного границей, ценой размера индекса.

Как измерить поисковую модель#

Для каждого эталонного вопроса поисковая модель запускается, в возвращённом списке находится позиция первого элемента, содержащего эталонный отрывок, и результаты агрегируются:

  • Доля попаданий в топ-kk (hit rate at k): доля вопросов, у которых эталонный отрывок нашёлся среди первых kk.
  • Средний обратный ранг (mean reciprocal rank): MRR=1∣Q∣∑q1rank(q)\mathrm{MRR} = \frac{1}{|Q|}\sum_{q} \frac{1}{\mathrm{rank}(q)}, где 1/∞=01/\infty = 0. Он вознаграждает правильный фрагмент на первом месте и гармонически обесценивает более низкие позиции.

Содержит ли возвращённый фрагмент эталонный отрывок, решается нечётким сравнением строк, а не поиском подстроки: отрывки скопированы с небольшими правками (дословно в выгрузке встречаются только 7 из 20, даже после нормализации пробелов), а фрагмент может содержать лишь часть отрывка. Критерий — сходство partial ratio не меньше 80%, и это само по себе источник ошибок: фрагмент другого поста, цитирующий то же предложение, засчитывается как попадание.

Разобранный пример#

Три вопроса; эталонный отрывок найден на позиции 1, на позиции 3 и вовсе не найден среди первых 10:

ВопросРанг1/rank1/\mathrm{rank}Hit@1Hit@3Hit@10
A11.000111
B30.333011
C—0000
среднее0.4440.330.670.67

Hit@kk отвечает на вопрос «как часто ответ вообще есть в том, что передаётся следующему этапу», MRR — «насколько высоко он стоит». Генератору, читающему первые 5 фрагментов, важен Hit@5; тому, кто читает только первый, — Hit@1, то есть стоит ли релевантный фрагмент первым. MRR засчитывает и позиции 2, 3, …, которых такой генератор не видит, поэтому он подытоживает качество ранжирования, а не измеряет успех поиска для этого генератора.

Та же оценка применяется ко всем вариантам ниже: плотной поисковой модели, поиску по родительским документам, BM25, гибриду и цепочке с переранжированием. Суть в процедуре: каждое изменение поиска измеряется на эталонной выборке до того, как к нему подключается генератор.

Три улучшения#

Каждое из них — вариант, который нужно оценить на эталонной выборке, с выигрышем, ожидаемым из механизма; ни у одного нет измеренного результата на этом корпусе.

Переранжирование кросс-энкодером#

Биэнкодер оценивает пару по двум независимым векторам и никогда не видит запрос и фрагмент вместе. Кросс-энкодер (cross-encoder) читает [CLS] q [SEP] d [SEP] и выдаёт одну оценку релевантности с полным вниманием между двумя текстами. Это точнее и гораздо дороже: ничего нельзя посчитать заранее, так что оценка всего корпуса потребовала бы NN проходов энкодера на запрос. Стандартный конвейер использует оба: дёшево извлечь первые 20–100 фрагментов, затем переоценить только их кросс-энкодером (здесь реранкер BAAI/bge-reranker-v2-m3) и переупорядочить. Цена — kk проходов энкодера на запрос; ожидаемый выигрыш — точность наверху списка, там, где читает генератор.

Гибридный поиск#

BM25 и плотный поиск ошибаются на разных запросах: на точных идентификаторах и на пересказах. Если запустить оба и слить списки, обе ошибки закрываются без всякого обучения. Сырые оценки двух методов живут в разных шкалах, поэтому списки обычно сливаются по рангам: слияние по обратным рангам (reciprocal rank fusion, RRF) оценивает фрагмент как ∑rwr/(60+rankr)\sum_r w_r/(60 + \mathrm{rank}_r) по спискам rr, в которых он встретился (Cormack et al., 2009). В гибриде, который используется здесь, у списка BM25 вес 0.4, у плотного — 0.6. На технических корпусах, полных имён и кодов, гибридный поиск обычно разумен по умолчанию.

Расширение запроса#

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

Вопросы с условиями: поиск с самозапросом#

Некоторые вопросы сочетают тему с условием на поле, которое документы несут в метаданных: лечение пневмонии антибиотиками в исследованиях, опубликованных после 2015 года, посты определённого автора о внимании. Тема — вопрос смысла, условие — нет. Косинусное сходство не может обеспечить после 2015 года: условие сдвигает вектор запроса к текстам, где упоминаются годы, а не к текстам этих лет. Поэтому векторные хранилища принимают фильтры по метаданным (metadata filters) — точные условия на сохранённые поля, применяемые вместе с поиском ближайших соседей. Кто-то должен написать фильтр.

Поиск с самозапросом (self-query retrieval) поручает это языковой модели. Модель получает описание документов, список полей метаданных с их типами и смыслом, операторы сравнения, которые поддерживает хранилище, и вопрос. Она возвращает структурированный запрос из двух частей: текст для сопоставления по сходству, из которого убраны условия, и фильтр из сравнений (eq, lt, gt, …), соединённых через and и or, либо пустой фильтр, если вопрос не ставит условий. Парсер проверяет фильтр по небольшой грамматике, транслятор переписывает его в собственный синтаксис фильтров хранилища, и хранилище выполняет поиск с фильтром. Для аннотаций, сохранённых с полем year:

question:  antibiotic treatment of pneumonia in studies published after 2015
query:     antibiotic treatment of pneumonia
filter:    gt("year", 2015)

Вынести условие из текста важно по двум причинам: вектор запроса теперь кодирует одну только тему, а условие выполняется точно, а не приближённо.

Издержки те же, что у любой модели в цикле. Каждый запрос требует одной генерации перед поиском. Модель может назвать несуществующее поле, использовать значение, которое в данных записано иначе (Pneumonia против pneumonia, полное имя против инициалов), или превратить расплывчатое слово в жёсткое условие (недавние как конкретный год). Неверный фильтр ломается молча: он убирает нужные документы, и поиск возвращает лучшие из ненужных. Самозапрос может фильтровать только по метаданным, сохранённым при индексации корпуса. Как и любое изменение поиска, его измеряют на эталонной выборке, прежде чем ему доверять.

Что изменилось#

BM25Плотный биэнкодер+ реранкерГибрид+ самозапрос
Сопоставляет потерминамсмыслу (обученные пары)смыслу, при совместном чтенииобоимсмыслу + точным условиям на поля
Обучениенетконтрастивное, большие наборы парконтрастивное, парыдополнительного нетдополнительного нет
Стоимость запросапоиск в обратном индексе1 проход энкодера + приближённый поиск соседей+ kk проходов энкодераоба поиска+ 1 генерация, приближённый поиск соседей с фильтром
Редкие идентификаторыточночасто пропускаетзависит от кандидатовточноточно, если сохранены как поле
Пересказпропускаетнаходитнаходитнаходитнаходит

Ограничения#

  • Распределение обучающих данных энкодера. Эмбеддинги, обученные на парах вопрос–ответ из интернета, хуже работают на юридических, медицинских корпусах или корпусах кода; узнать это можно только по эталонной выборке.
  • Границы фрагментов разрезают ответы; перекрытие и поиск по родительским документам смягчают это, но не устраняют.
  • Длинные и многотемные фрагменты кодируются размытым вектором; лекарство — нарезка, которая создаёт предыдущую проблему.
  • Свежесть. Изменение документа требует заново его закодировать; индекс — это снимок.
  • Эталонная выборка мала и составлена вручную, а критерий нечёткого совпадения добавляет шум. При 20 вопросах один вопрос сдвигает Hit@kk на 5 пунктов, так что разница в вопрос-два между поисковыми моделями — не вывод.
  • Сходство — не релевантность. Фрагмент на ту же тему, не содержащий ответа, получает высокую оценку; переранжирование помогает, остальное ловит оценка генерации (следующие заметки).
  • Фильтры, написанные моделью, ломаются молча. Фильтр самозапроса с неверным полем или значением убирает ответ без всяких признаков в результатах, и он может использовать только метаданные, сохранённые при индексации.
  • Индекс привязан к своему энкодеру. Постоянный индекс нужно запрашивать той же моделью и с теми же инструкциями, с которыми он построен; смена любого из них означает повторное кодирование корпуса.

Что нужно запомнить#

  • BM25: насыщенные счётчики терминов с нормировкой по длине; точен на редких терминах, слеп к пересказу.
  • Плотный поиск: контрастивно обученный биэнкодер, косинус на нормированных векторах, векторный индекс; соблюдать соглашения симметричных и асимметричных моделей.
  • Модели эмбеддингов, управляемые инструкцией (INSTRUCTOR), обобщают префикс до инструкции, называющей предметную область, тип текста и задачу; у фрагментов и у запросов — по одной неизменной инструкции.
  • Сохранять индекс на диск, чтобы кодировать корпус один раз; запрашивать его тем энкодером, который его построил.
  • Самозапрос: языковая модель разбивает вопрос на текстовый запрос и фильтр по метаданным; фильтр точен, а неверный фильтр молча убирает ответ.
  • Резать на единицы, держащие одну мысль; индексировать мелко, возвращать крупно (поиск по родительским документам); перекрытие страхует границы.
  • Измерять на эталонной выборке через Hit@kk и MRR до того, как браться за генерацию.
  • Переранжировать лучших кандидатов кросс-энкодером; сливать списки BM25 и плотного поиска, чтобы закрыть обе ошибки; принимать любой из вариантов, только если он выигрывает на эталонной выборке.

Ноутбук#

week09_RAG.ipynb. Номера ячеек считают все ячейки, включая текстовые, начиная с 1. Приведённые числа взяты из прогона, сохранённого в курсе; у ячеек, изменённых с тех пор под текущие версии библиотек, сохранённого выхода нет.

  • Ячейки 6–14 загружают выгрузку постов, строят график длин постов, печатают пять длинных постов и загружают эталонную выборку из 20 пар вопрос–отрывок.
  • Ячейки 19–24 оборачивают посты в документы; ячейки 27–45 пробуют нарезку по токенам, по предложениям, семантическую и рекурсивную на пяти длинных постах и печатают фрагменты и число токенов в них.
  • Ячейки 47–49 настраивают симметричный энкодер bge-m3 (нормированные векторы), с асимметричной альтернативой e5 в комментарии; ячейка 51 строит индекс Qdrant в памяти через QdrantVectorStore (сохранённый в курсе прогон остановился здесь на ошибке версии библиотеки, поэтому у ячеек поиска после неё нет сохранённых выходов).
  • Ячейки 55–60 настраивают поиск по родительским документам с дочерними фрагментами по 120 символов.
  • Ячейки 62–67 реализуют оценку на эталонной выборке (Hit@k для k∈{1,3,5,10}k \in \{1, 3, 5, 10\} и MRR), сначала с точным совпадением подстроки, затем с порогом нечёткого совпадения 80%; в коде доля попаданий называется P@k. К этому моменту поиск по родительским документам уже добавил свои дочерние фрагменты в ту же коллекцию Qdrant, так что этот плотный поиск идёт по целым постам и 120-символьным кускам вместе.
  • Ячейки 68–71 строят BM25 (первые 10) и гибрид 0.4 / 0.6 со слиянием по обратным рангам через EnsembleRetriever и оценивают каждый. Оценка запускается для плотной поисковой модели, поиска по родительским документам, BM25, гибрида и вариантов с переранжированием; численные выводы этих ячеек не сохранились, поэтому значений Hit@k и MRR в заметке нет.
  • Ячейки 77–78 перефразируют разговорный запрос моделью с инструкциями на 3 млрд параметров.
  • Ячейки 81–85 загружают кросс-энкодер bge-reranker-v2-m3 и переранжируют то, что возвращает поиск по родительским документам. Цикл просит 20 кандидатов, но эта поисковая модель игнорирует k, переданный в invoke, поэтому реранкер переупорядочивает не больше четырёх постов: родителей четырёх ближайших дочерних фрагментов.

Ноутбук с заданием по медицинскому RAG из курса HSE-LAMBDA Deep Generative Models, 05_hw_LLM-RAG.ipynb, строит свой индекс с LangChain (ячейки нумеруются так же). Это заготовка, в которой ключевые строки оставлены для заполнения, поэтому выходов у неё нет. Её данные, MeDAL, и ячейки генерации описаны в заметке о RAG.

  • Ячейки 7–8 читают CSV через CSVLoader и его генератор .lazy_load(), который выдаёт по одному Document на строку, не загружая файл в память. По умолчанию каждый столбец становится строкой name: value в тексте документа, так что идентификатор аннотации и индекс позиции кодируются вместе с аннотацией, а в метаданных остаются только путь к файлу и номер строки. content_columns=["TEXT"] и metadata_columns=["LABEL"] оставляют текстом аннотацию и переносят метку в метаданные, где её может использовать фильтр.
  • Ячейки 9–10 создают HuggingFaceInstructEmbeddings — обёртку LangChain для INSTRUCTOR — с именем модели, оставленным для заполнения; по умолчанию обёртка берёт hkunlp/instructor-large, описанную выше модель на 335 млн параметров. Её инструкции по умолчанию, «Represent the document for retrieval: » для фрагментов и «Represent the question for retrieving supporting documents: » для запросов, не называют предметной области; их меняют embed_instruction и query_instruction.
  • Ячейки 11–16 создают хранилище Chroma с persist_directory='DB', добавляют первые N_DOCS = 2000 строк — по одной аннотации на документ, без нарезки (медианная длина аннотации MeDAL — 150 слов), — вызывают .persist() и печатают содержимое каталога. Цикл должен сам останавливаться на N_DOCS, потому что total= задаёт только длину индикатора прогресса, а добавление документов батчами кодирует их за меньшее число более крупных вызовов энкодера.
  • Ячейки 18–20 импортируют SelfQueryRetriever и AttributeInfo — описание поля метаданных, которое читает модель самозапроса, — а строка установки включает lark, парсер его фильтров. Сама заготовка строит обычный vectordb.as_retriever() — поиск по сходству, по умолчанию возвращающий четыре документа, — и проверяет его на запросе с сокращением «ceftobiprole bpr». Поисковой модели с самозапросом по этому корпусу нужны поля в метаданных, например LABEL, а настройки CSVLoader по умолчанию их туда не кладут.

Проблемы в этих ячейках:

  • Строка установки фиксирует sentence-transformers==2.2.2 — версию, под которую написан пакет InstructorEmbedding. Эта версия импортирует cached_download из huggingface_hub, а в huggingface_hub 0.26 эту функцию удалили, так что в текущем окружении импорт падает, если не зафиксировать ещё и huggingface_hub<0.26.
  • from langchain.embeddings import HuggingFaceInstructEmbeddings, from langchain.vectorstores import Chroma, from langchain.chains.query_constructor.base import AttributeInfo и from langchain.retrievers.self_query.base import SelfQueryRetriever — пути LangChain до версии 1.0. Строка установки не фиксирует версию LangChain и получает 1.x, где эти классы находятся в langchain-community, langchain-chroma и langchain-classic.
  • .persist() есть только у устаревшего класса Chroma из langchain-community, где начиная с Chroma 0.4 этот метод ничего не делает: хранилище с persist_directory записывается на диск автоматически. У класса langchain_chroma.Chroma, который строка установки тоже ставит, такого метода нет, и вызов падает.

Дальше#

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

Литература#