Многовекторни (с късно взаимодействие) модели за вграждане със Sentence Transformers
MultiVectorEncoder, предназначен за извличане с късно взаимодействие в стил ColBERT. Всяка контролна точка на PyLate и всяка контролна точка на Stanford-NLP ColBERT се зареждат директно в него, а моделите на colpali-engine за визуално извличане на документи също могат да се използват чрез същия познат API, който вече използвате за плътни, разредени и преранжиращи модели.
Докато обикновеният модел за вграждания компресира целия текст в един вектор, многовекторният модел запазва по един вектор за всеки токен и изчислява оценка за заявката спрямо документа чрез оператора MaxSim. Това съхранява информацията за съвпадения на ниво токен, която един-единствен вектор трябва да усредни, което обикновено означава по-силно извличане за сметка на по-голям индекс. Това е и най-съвременният подход за визуално извличане на документи, при който текстова заявка се съпоставя директно с изображения на страници, без междинна стъпка за OCR.
В тази публикация ще ви покажем как да използвате тези модели: зареждане на различните формати на контролни точки, кодиране и изчисляване на оценки, включването им в стек за търсене, използването им върху изображения на страници и поддържането на достъпен индекс. Всичко по-долу работи с обикновена команда pip install -U sentence-transformers.
Съдържание
- Какво представляват многовекторните модели?
- Операторът MaxSim
- Какво печелите и каква е цената
- Инсталиране
- Зареждане на модел
- Проверка на конфигурацията на контролна точка
- Кодиране на заявки и документи
- Изчисляване на оценки с MaxSim
- Големина на оценката и MeanMaxSim
- Семантично търсене
- Извличане и преранжиране
- Индексиране
- Визуално извличане на документи
- Извличане на аудио
- Извличане на видео
- Интерпретируемост
- Обединяване на токени
- Ускоряване на инференцията
- Оценяване на модел
- Преминаване от PyLate или colpali-engine
- Поддържани модели
- Благодарности
- Допълнителни ресурси
Какво представляват многовекторните модели?
Плътният модел за вграждания прочита текст и връща един вектор с фиксиран размер. Всичко, което моделът е забелязал, трябва да се побере в тези 384, 768 или 1024 числа, а сходството е един скаларен добутък между две такива обобщения. Това работи забележително добре, но компресията е губеща по специфичен начин: рядко срещана именувана същност, точен идентификатор или една ключова клауза в дълъг откъс трябва да се конкурират за място в същия вектор. Заявка с няколко изисквания едновременно се сблъсква със същата преграда. При „зелен диван с дървени крака и заоблени възглавници“ един вектор трябва да смеси и четирите характеристики в една точка, така че зелен диван с неправилни крака се оказва близо до този, който действително сте поискали.
Многовекторният модел (наричан още модел с късно взаимодействие или модел в стил ColBERT, по името на статията за ColBERT) пропуска тази компресия. Той изпълнява същия трансформър, но вместо да обединява вгражданията на токените в един вектор, проектира всяко вграждане на токен до малка размерност (класически 128) и запазва всички. Документ от 9 токена се превръща в матрица 9x128, а не във вектор 1x128.
Взаимодействието между заявката и документа се отлага до момента на изчисляване на оценката, откъдето идва и названието „късно взаимодействие“. Крос-енкодерът взаимодейства рано: двата текста преминават през модела заедно, което е точно, но не оставя нищо за предварително изчисляване, тъй като всеки документ трябва да се кодира отново за всяка нова заявка. Би-енкодерът, какъвто е описаният по-горе плътен модел за вграждания, почти не взаимодейства (един скаларен добутък между две готови обобщения), което позволява колекцията да се кодира веднъж и да се правят бързи заявки. Късното взаимодействие е по средата: документите все още се кодират независимо и могат да се индексират офлайн, но при изчисляването на оценката всеки токен от заявката се сравнява с всеки токен от документа, което оставя много повече възможности за взаимодействие между тях.
Операторът MaxSim
Изчисляването на оценката използва MaxSim: за всеки токен от заявката се взема най-високото му сходство с който и да е токен от документа, след което тези максимални стойности се сумират по заявката.
Тъй като вгражданията на токените са L2-нормализирани, всеки от тези скаларни добутъци е косинусово сходство в [-1, 1], така че общата сума попада в интервала [-num_query_tokens, num_query_tokens].
Можете да възприемате оператора като меко подравняване: всеки токен от заявката сочи към токена от документа, който най-добре го обяснява, а оценката показва доколко документът подкрепя заявката като цяло.
Подравняването не е задължително лексикално, тъй като вгражданията на токените са контекстуализирани. Кодирайте „Where do penguins live?“ спрямо „Penguins inhabit Antarctica.“ с lightonai/mLateOn и токенът на заявката live намира най-доброто си съвпадение с inhabit при 0.94 — дума, с която няма общи символи! Именно това не може да направи лексикалното извличане: BM25 и сродните му методи изискват самия термин, така че синонимите и парафразите им се изплъзват. Разбира се, плътните модели за вграждания също преодоляват тази разлика. Късното взаимодействие добавя възможността да го прави, без да се отказва от обратната посока: когато е важно точното съвпадение (продуктов код, фамилно име, име на функция), MaxSim все още разполага с този токен самостоятелно, докато моделът с един вектор е трябвало да го усредни с всичко останало. Това също не е съпоставяне едно към едно, тъй като няколко токена от заявката редовно се установяват върху един и същ токен от документа.
Какво печелите и каква е цената
Печелите качество на извличането, особено при заявки, при които една конкретна част от документа е това, което го прави релевантен; при заявки с няколко изисквания като тази за дивана, където всяко изискване може да намери свои собствени доказателства; и при данни извън домейна, където компресията на плътния модел е била настроена за различно разпределение. Тази компресия се научава от заявките за обучение, така че моделът се научава да запазва необходимото за тях и да премахва всичко останало, което може да включва точно това, за което питат производствените ви заявки. Ефектът нараства с дължината на документа, тъй като повече текст трябва да се побере в същия вектор с фиксиран размер.
Цената е размерът на индекса. Един вектор за всеки токен вместо един вектор за документ означава много повече вектори, частично компенсирани от по-малката размерност. Кодирането на 4 874 откъса от Natural Questions с lightonai/LateOn произведе 608 414 векторa на токени, или средно 124,8 на откъс:
| Представяне | Вектори | Размерности | размер float32 |
|---|---|---|---|
Плътно, all-MiniLM-L6-v2 |
4 874 | 384 | 7,5 MB |
Плътно, gte-modernbert-base |
4 874 | 768 | 15,0 MB |
Многовекторно, LateOn |
608 414 | 128 | 311,5 MB |
Това е около 42 пъти повече място от индекса MiniLM, или 62 KiB на откъс. Индексите обаче често се компресират: например същите 608 414 вектора заемат 92 MB като бърз индекс plaid, тъй като PLAID съхранява идентификатор на центроид плюс квантизирано остатъчно значение за всеки вектор, вместо самия вектор. За сравнение, плътен модел с 4096 измерения като Qwen3-Embedding-8B би изисквал около 80 MB за същите 4 874 откъса, така че компресираният многовекторен индекс е в същия диапазон като плътните индекси, които хората вече използват. Обединяването на токени намалява броя на векторите още преди всичко това, а извличането и преранжирането премахват нуждата от изграждане на индекс.
PyLate се споменава многократно в тази публикация, затова накратко: Sentence Transformers поддържаше плътни и разредени модели, но не и късно взаимодействие, така че LightOn изгради PyLate върху него, за да запълни тази празнина, като добави необходимите за тези модели компоненти за обучение, инференция и извличане. Голяма част от това, което ще заредите по-долу, е обучено с него, а LightOn изгради и екосистема около него, включително fast-plaid, индекса за късно взаимодействие, който се появява в раздела „Индексиране“. С v6.0 тези възможности вече са част от самия Sentence Transformers.
Имайки предвид този компромис, нека стартираме модел.
Инсталиране
Многовекторните модели работят със стандартна инсталация:
pip install -U sentence-transformers
За визуално извличане на документи в стил ColPali са нужни и зависимостите за изображения (вижте Инсталиране за всички допълнителни компоненти и Мултимодални модели за вграждания и преранжиране за обща информация за мултимодалната поддръжка):
pip install -U "sentence-transformers[image]"
Sentence Transformers v6.0 изисква
transformersv5.x,torch2.2+ иhuggingface-hubv1.x. Ако фиксирате по-стари версии на някой от тях, първо планирайте надстройката. Вижте Ръководството за миграция за пълния списък с несъвместими промени.
Зареждане на модел
Зареждането на многовекторен модел изглежда точно като зареждането на всеки друг модел на Sentence Transformers:
from sentence_transformers import MultiVectorEncoder
model = MultiVectorEncoder("lightonai/LateOn")
За да откриете работещи модели, потърсете етикетите multi-vector и sentence-transformers tags в Hub. Всеки модел с тези етикети се зарежда с горния ред, независимо дали първоначално е бил контролна точка на PyLate, Stanford-NLP ColBERT или модел от семейството ColPali за визуално извличане на документи. Работим по екосистемата, за да добавим този етикет към всеки съвместим модел, така че списъкът продължава да расте.
Под капака MultiVectorEncoder прочита всеки от форматите, в които тези контролни точки са публикувани през годините, така че контролните точки на PyLate и Stanford-NLP се зареждат директно дори когато етикетът все още не е добавен:
from sentence_transformers import MultiVectorEncoder
model = MultiVectorEncoder("lightonai/LateOn")
model = MultiVectorEncoder("mixedbread-ai/mxbai-edge-colbert-v0-17m")
model = MultiVectorEncoder("LiquidAI/LFM2.5-ColBERT-350M", trust_remote_code=True)
model = MultiVectorEncoder("colbert-ir/colbertv2.0")
model = MultiVectorEncoder("answerdotai/answerai-colbert-small-v1")
model = MultiVectorEncoder("answerdotai/ModernBERT-base")
Моделите за визуално извличане на документи са изключение. Контролните точки от семейството ColPali се доставят в собствения формат на colpali-engine, който не носи информация, използваема от Sentence Transformers, така че към хранилището на всяка от тях трябва да се добави малка конфигурация, преди да се зареди. По-голямата част от тази работа е завършена и очаква сливане. Вижте „Поддържани модели“ за текущото състояние и как да ги зареждате днес.
Проверка на конфигурацията на контролна точка
Многовекторните модели съдържат няколко настройки, различни за всяка контролна точка: маркерни префикси за заявки и документи, ограничения на дължината, дали заявките се допълват с токени [MASK] и кои токени се пропускат при оценяване на документите. Всички те се намират в конфигурациите на модулите, така че print(model) показва точно какво сте заредили. Ето оригиналната контролна точка ColBERTv2, която допълва всяка заявка точно до 32 токена и съкращава документите до 180:
from sentence_transformers import MultiVectorEncoder
model = MultiVectorEncoder("colbert-ir/colbertv2.0")
print(model)
"""
MultiVectorEncoder(
(0): Transformer({..., 'document_length': 180,
'query_expansion': {'strategy': 'fixed', 'attend': False, 'token': None, 'length': 32}})
(1): Dense({'in_features': 768, 'out_features': 128, 'bias': False, ...})
(2): MultiVectorMask({'skiplist_words': ['!', '"', '#', ...], 'skiplist_tasks': ['document'], ...})
(3): Normalize({...})
)
"""
print(model.prompts)
Това е класическият конвейер на ColBERT: Transformer, който създава контекстуализирани вграждания на токени; нивовият за токени Dense, който проектира всеки от тях до 128 измерения; MultiVectorMask, който решава кои токени участват при изчисляването на оценката; и нивовият за токени Normalize. Другите контролни точки използват различни стойности. lightonai/GTE-ModernColBERT-v1 използва същите четири модула с подсказки [Q] и [D] , без разширяване на заявката и с ограничения съответно 48 и 300.
Рядко ще се налага да променяте нещо от това, тъй като всяка публикувана контролна точка е конфигурирана сама. Това е важно, когато изграждате модел от необработен гръбнак — описано е в Създаване на персонализирани модели.
Все пак има една стойност, която си струва да проверите спрямо собствените си данни. document_length съкращава текста, така че всичко след тази граница никога не достига до индекса. Например откъс от 662 токена, преминал през ограничението на LateOn от 300, се връща като 273 вектора, а останалата част от откъса просто изчезва. Повечето от тези контролни точки са обучени върху кратки откъси, така че ако вашите сегменти са по-дълги от ограничението, можете да го увеличите за едно извикване с encode_document(..., processing_kwargs={"text": {"max_length": 512}}), като имате предвид, че ще изпълнявате модела отвъд дължината, върху която е обучен, и че индексът ще нараства приблизително пропорционално. Многовекторните модели обикновено понасят това добре. Върху MLDR, бенчмарк за извличане на дълги документи, многоезичните варианти на двойката по-горе ясно показват разликата: mLateOn постига 77,92 спрямо 51,59 за mDenseOn.
Кодиране на заявки и документи
Многовекторните модели са асиметрични: заявките и документите преминават през различни префикси, различни ограничения на дължината и различни маски за оценяване. За разлика от много плътни модели, при които двете са взаимозаменяеми, encode_query() и encode_document() са задължителни за получаване на коректни вграждания:
from sentence_transformers import MultiVectorEncoder
model = MultiVectorEncoder("lightonai/mLateOn")
queries = ["What is the capital of France?"]
documents = [
"Paris is the capital of France.",
"Berlin is the capital and largest city of Germany, by both area and population.",
]
query_embeddings = model.encode_query(queries)
document_embeddings = model.encode_document(documents)
print(query_embeddings[0].shape)
print(document_embeddings[0].shape, document_embeddings[1].shape)
Обърнете внимание какво получавате: списък от двумерни тензори, по един за всеки вход, всеки с форма (num_tokens, embedding_dim). За разлика от плътните вграждания, не можете да ги подредите в един правоъгълен тензор, защото всеки вход има собствен брой токени. Вторият документ е по-дълъг от първия, затова се връща като по-висока матрица.
Всяко извикване прилага собствената рецепта на модела. encode_query добавя префикса на заявката, разширява заявката до фиксирана дължина, ако контролната точка го изисква, и я ограничава до дължината на заявката. encode_document добавя префикса на документа, ограничава дължината до тази на документа и премахва токените от списъка за пропускане (при повечето контролни точки — пунктуацията) от маската за оценяване.
Всички обичайни аргументи на encode() продължават да се прилагат, така че batch_size, show_progress_bar, convert_to_tensor, device и пуловете за многопроцесна работа функционират както очаквате:
document_embeddings = model.encode_document(
documents,
batch_size=64,
convert_to_tensor=True,
show_progress_bar=True,
)
Изчисляване на оценки с MaxSim
model.similarity() изчислява пълната матрица MaxSim за всички двойки:
from sentence_transformers import MultiVectorEncoder
model = MultiVectorEncoder("lightonai/LateOn")
query_embeddings = model.encode_query(["Which planet is known as the Red Planet?"])
document_embeddings = model.encode_document([
"Venus is often called Earth's twin because of its similar size and proximity.",
"Mars, known for its reddish appearance, is often referred to as the Red Planet.",
"Jupiter, the largest planet in our solar system, has a prominent red spot.",
"Saturn, famous for its rings, is sometimes mistaken for the Red Planet.",
])
scores = model.similarity(query_embeddings, document_embeddings)
print(scores)
Марс печели, както и трябва. Обърнете внимание колко близки са следващите резултати: Сатурн също съдържа буквалната фраза „the Red Planet“, а Юпитер е планета с червено петно, така че операторът на ниво токени има за какво да се захване и в трите случая. Важен е редът на резултатите.
Оценките често са толкова близки, както показва GLInt, измервайки разпределението в пълен набор от кандидати. MaxSim взема максимум за всеки токен от заявката, така че документът обикновено ще даде на всеки токен от заявката някакво прилично най-добро съвпадение, а оценките започват от определено базово ниво. Контекстуализираните вграждания на токени също са анизотропни и се групират в тесен конус, вместо да са равномерно разпределени, така че дори произволни двойки токени обикновено получават високи оценки.
Съществува и model.similarity_pairwise(), когато вече разполагате със съпоставени двойки и искате само оценките на двойките, а не пълната матрица на сходствата:
scores = model.similarity_pairwise(query_embeddings, document_embeddings[:1])
print(scores)
Големина на оценката и MeanMaxSim
MaxSim сумира по токените на заявката, така че големината му зависи от броя токени в заявката. Това означава, че не можете да сравнявате оценки между модели с различни рецепти за заявки. LateOn кодира заявката за Червената планета по-горе като 12 токена. Ако изпълните същата заявка и същите документи през ColBERTv2, който допълва и съкращава всяка заявка точно до 32 токена, оценките ще попаднат в напълно различен диапазон:
model = MultiVectorEncoder("colbert-ir/colbertv2.0")
print(scores)
В рамките на един модел ви е нужен само редът на резултатите, но ако искате оценки в ограничен диапазон, сменете функцията за сходство на модела към MeanMaxSim, която дели на броя токени в заявката. Отново за LateOn:
model = MultiVectorEncoder("lightonai/LateOn", similarity_fn_name="meanmaxsim")
print(model.similarity(query_embeddings, document_embeddings))
Сега всяка оценка е средно косинусово сходство в [-1, 1], макар че на практика ще виждате само [0, 1].
Семантично търсене
Ако корпусът ви е малък, изчерпателният MaxSim върху целия корпус е най-простото работещо решение. Кодирайте корпуса веднъж, след което изчислявайте оценката на всяка заявка спрямо всичко:
import time
from datasets import load_dataset
from sentence_transformers import MultiVectorEncoder
dataset = load_dataset("sentence-transformers/natural-questions", split="train[:5000]")
corpus = list(dict.fromkeys(dataset["answer"]))
model = MultiVectorEncoder("lightonai/LateOn")
corpus_embeddings = model.encode_document(corpus, convert_to_tensor=True, show_progress_bar=True)
query = "when did richmond last play in a preliminary final"
start = time.perf_counter()
query_embeddings = model.encode_query([query], convert_to_tensor=True)
scores = model.similarity(query_embeddings, corpus_embeddings)[0]
top_scores, top_indices = scores.topk(3)
print(f"Search took {(time.perf_counter() - start) * 1000:.1f}ms")
for score, index in zip(top_scores.tolist(), top_indices.tolist()):
print(f"{score:.4f} {corpus[index][:100]}")
"""
Search took 122.7ms
11.9192 Richmond Football Club Richmond began 2017 with 5 straight wins, a feat it had not achieved
11.7591 2017 AFL Grand Final The 2017 AFL Grand Final was an Australian rules football game contest
11.6710 Battle of Appomattox Court House The Battle of Appomattox Court House (Virginia, U.S.), fou
"""
Тези 4 874 откъса са кодирани за 20 секунди на RTX 3090, а всяко търсене отнема около 120 ms от край до край, като по-голямата част от времето се изразходва за изчисляване на MaxSim спрямо всички 608 414 вектора на токени. Това е точно, но се мащабира линейно с общия брой токени в корпуса и съхранява всеки вектор на токен в паметта, така че го използвайте при няколко хиляди документа, а не при няколко милиона. Работещата версия на този скрипт е semantic_search.py.
При по-голям размер ви е нужен истински индекс за късно взаимодействие, какъвто Sentence Transformers не доставя. Това не е проблем: тези индекси съхраняват резултата от encode_document, така че кодирате тук и подавате вгражданията на токените към нещо, създадено за тях. Разделът „Индексиране“ съдържа работещи откъси за четири от възможностите, а следващият раздел показва как да пропуснете индекса изцяло.
Извличане и преранжиране
Можете да получите качеството на късното взаимодействие и без да поддържате индекс за късно взаимодействие, като използвате многовекторен модел като преранжиращ модел. Бърз би-енкодер стеснява голям корпус до малък брой кандидати, след което многовекторният модел преизчислява оценките само на тях:
from datasets import load_dataset
from sentence_transformers import MultiVectorEncoder, SentenceTransformer
from sentence_transformers.util import semantic_search
dataset = load_dataset("sentence-transformers/natural-questions", split="train[:50000]")
corpus = list(dict.fromkeys(dataset["answer"]))
retriever = SentenceTransformer("jinaai/jina-embeddings-v5-text-nano-retrieval")
reranker = MultiVectorEncoder("perplexity-ai/pplx-embed-v1-late-0.6b", trust_remote_code=True)
corpus_embeddings = retriever.encode_document(corpus, convert_to_tensor=True, show_progress_bar=True)
query = "when did richmond last play in a preliminary final"
hits = semantic_search(retriever.encode_query([query], convert_to_tensor=True), corpus_embeddings, top_k=50)[0]
candidates = [corpus[hit["corpus_id"]] for hit in hits]
query_embeddings = reranker.encode_query([query])
document_embeddings = reranker.encode_document(candidates)
scores = reranker.similarity(query_embeddings, document_embeddings)[0]
for index in scores.argsort(descending=True)[:3].tolist():
print(f"{scores[index].item():.4f} {candidates[index][:100]}")
Само 50-те кандидати някога се кодират като многовектори, така че индексът ви остава нормален плътен индекс, а векторите на токените са временни. Това изпълнява същата роля като крос-енкодера в стек за извличане и преранжиране, но многовекторният модел е значително по-евтин за кандидат. Кодирате документите в един пакет и изчислявате оценките с матрично умножение, вместо с едно предаване напред за всяка двойка заявка–документ. Работещият скрипт е retrieve_rerank.py, който извежда времената за двата етапа.
Индексиране
Няколко бази данни за вектори индексират и оценяват многовектори нативно: Qdrant от v1.10, Weaviate от v1.29, Vespa от години, LanceDB от v0.15.0 и VectorChord, който добавя оператор MaxSim към Postgres, какъвто стандартният pgvector няма. Milvus се присъедини към тях във v2.6.4 под „array-of-structs“, а не под несвързаната функция, която нарича търсене с многовектори. Ако предпочитате изобщо да не стартирате сървър, fast-plaid на LightOn е достъпен с една команда pip install и реализира PLAID директно, а PyLate го обвива в по-пълен стек за извличане.
Няколко други решения покриват само част от нуждите. OpenSearch и Elasticsearch могат да преизчисляват оценките на кандидатите с MaxSim, но не и да извличат чрез него, а полето на Elasticsearch допълнително е в технически преглед и е достъпно само на ниво Enterprise. turbopuffer предлага индексиране с късно взаимодействие в частна бета версия.
Следващите откъси индексират текст, но нищо в тях не е специфично за текста. encode_document връща същия списък от матрици с вектори на токени, независимо дали документът е откъс, изображение на страница, аудиоклип или видео, така че моделите в стил ColPali от раздела „Визуално извличане на документи“ могат да се използват във всеки от тях без промени. Просто има повече вектори на документ, което прави обединяването на токени особено полезно.
Преведено автоматично от английски. Оригиналната статия е на връзката по-долу.
