SemanticCaches.jl — очень хакерская реализация семантического кэша для приложений ИИ, позволяющая экономить время и деньги при повторных запросах. Это не особенно быстро, потому что мы пытаемся предотвратить вызовы API, которые могут занять даже 20 секунд.
Обратите внимание, что мы используем крошечную модель BERT с максимальным размером фрагмента 512 токенов, чтобы обеспечить быстрое локальное внедрение, работающее на ЦП. Для более длинных предложений мы разбиваем их на несколько частей и учитываем их среднее вложение, но используйте это осторожно! Задержка может резко возрасти и стать хуже, чем при простом вызове исходного API.
Чтобы установить SemanticCaches.jl, просто добавьте пакет с помощью менеджера пакетов Julia:
using Pkg;
Pkg . activate ( " . " )
Pkg . add ( " SemanticCaches " )
# # This line is very important to be able to download the models!!!
ENV [ " DATADEPS_ALWAYS_ACCEPT " ] = " true "
using SemanticCaches
sem_cache = SemanticCache ()
# First argument: the key must always match exactly, eg, model, temperature, etc
# Second argument: the input text to be compared with the cache, can be fuzzy matched
item = sem_cache ( " key1 " , " say hi! " ; verbose = 1 ) # notice the verbose flag it can 0,1,2 for different level of detail
if ! isvalid (item)
@info " cache miss! "
item . output = " expensive result X "
# Save the result to the cache for future reference
push! (sem_cache, item)
end
# If practice, long texts may take too long to embed even with our tiny model
# so let's not compare anything above 2000 tokens =~ 5000 characters (threshold of c. 100ms)
hash_cache = HashCache ()
input = " say hi "
input = " say hi " ^ 1000
active_cache = length (input) > 5000 ? hash_cache : sem_cache
item = active_cache ( " key1 " , input; verbose = 1 )
if ! isvalid (item)
@info " cache miss! "
item . output = " expensive result X "
push! (active_cache, item)
end
Основная цель создания этого пакета заключалась в кэшировании дорогостоящих вызовов API к моделям GenAI.
Система предлагает точное сопоставление (быстрее, HashCache
) и поиск семантического сходства (медленнее, SemanticCache
) входных данных STRING. Кроме того, все запросы сначала сравниваются с «ключом кэша», который представляет собой ключ, который всегда должен точно совпадать, чтобы запросы считались взаимозаменяемыми (например, одна и та же модель, тот же поставщик, та же температура и т. д.). Вам необходимо выбрать соответствующий ключ кэша и ввести его в зависимости от вашего варианта использования. Этот выбор по умолчанию для ключа кэша должен быть именем модели.
Что происходит, когда вы вызываете кеш (указываете cache_key
и string_input
)?
cache.items
.cache_key
просматривается, чтобы найти индексы соответствующих элементов в items
. cache_key
не найден, мы возвращаем CachedItem
с пустым полем output
(т. е. isvalid(item) == false
).string_input
, используя крошечную модель BERT, и нормализуем вложения (чтобы потом было легче сравнивать косинусное расстояние).min_similarity
, мы возвращаем кэшированный элемент (выходные данные можно найти в поле item.output
). Если мы не нашли ни одного кэшированного элемента, мы возвращаем CachedItem
с пустым полем output
(т. е. isvalid(item) == false
). После того, как вы вычислите ответ и сохраните его в item.output
, вы можете отправить элемент в кеш, вызвав push!(cache, item)
.
Основываясь на ваших знаниях о сделанных вызовах API, вам необходимо определить: 1) ключ кэша (отдельное хранилище кэшированных элементов, например, разных моделей или температур) и 2) как распаковать HTTP-запрос в строку (например, развернуть и присоединиться к отформатированному содержимому сообщения для OpenAI API).
Вот краткое описание того, как можно использовать SemanticCaches.jl с PromptingTools.jl.
using PromptingTools
using SemanticCaches
using HTTP
# # Define the new caching mechanism as a layer for HTTP
# # See documentation [here](https://juliaweb.github.io/HTTP.jl/stable/client/#Quick-Examples)
module MyCache
using HTTP, JSON3
using SemanticCaches
const SEM_CACHE = SemanticCache ()
const HASH_CACHE = HashCache ()
function cache_layer (handler)
return function (req; cache_key :: Union{AbstractString,Nothing} = nothing , kw ... )
# only apply the cache layer if the user passed `cache_key`
# we could also use the contents of the payload, eg, `cache_key = get(body, "model", "unknown")`
if req . method == " POST " && cache_key != = nothing
body = JSON3 . read ( copy (req . body))
if occursin ( " v1/chat/completions " , req . target)
# # We're in chat completion endpoint
input = join ([m[ " content " ] for m in body[ " messages " ]], " " )
elseif occursin ( " v1/embeddings " , req . target)
# # We're in embedding endpoint
input = body[ " input " ]
else
# # Skip, unknown API
return handler (req; kw ... )
end
# # Check the cache
@info " Check if we can cache this request ( $( length (input)) chars) "
active_cache = length (input) > 5000 ? HASH_CACHE : SEM_CACHE
item = active_cache ( " key1 " , input; verbose = 2 ) # change verbosity to 0 to disable detailed logs
if ! isvalid (item)
@info " Cache miss! Pinging the API "
# pass the request along to the next layer by calling `cache_layer` arg `handler`
resp = handler (req; kw ... )
item . output = resp
# Let's remember it for the next time
push! (active_cache, item)
end
# # Return the calculated or cached result
return item . output
end
# pass the request along to the next layer by calling `cache_layer` arg `handler`
# also pass along the trailing keyword args `kw...`
return handler (req; kw ... )
end
end
# Create a new client with the auth layer added
HTTP . @client [cache_layer]
end # module
# Let's push the layer globally in all HTTP.jl requests
HTTP . pushlayer! (MyCache . cache_layer)
# HTTP.poplayer!() # to remove it later
# Let's call the API
@time msg = aigenerate ( " What is the meaning of life? " ; http_kwargs = (; cache_key = " key1 " ))
# The first call will be slow as usual, but any subsequent call should be pretty quick - try it a few times!
Вы также можете использовать его для вложений, например,
@time msg = aiembed ( " how is it going? " ; http_kwargs = (; cache_key = " key2 " )) # 0.7s
@time msg = aiembed ( " how is it going? " ; http_kwargs = (; cache_key = " key2 " )) # 0.02s
# Even with a tiny difference (no question mark), it still picks the right cache
@time msg = aiembed ( " how is it going " ; http_kwargs = (; cache_key = " key2 " )) # 0.02s
Вы можете удалить слой кэша, вызвав HTTP.poplayer!()
(и добавить его снова, если вы внесли какие-либо изменения).
Вы можете проверить кеш, вызвав MyCache.SEM_CACHE
(например, MyCache.SEM_CACHE.items[1]
).
Как спектакль?
Большая часть времени будет потрачена на 1) крошечные встраивания (для больших текстов, например, тысячи токенов) и на вычисление косинусного сходства (для больших кешей, например, более 10 тыс. элементов).
Для справки: встраивание небольших текстов, таких как вопросы, занимает всего несколько миллисекунд. Встраивание 2000 токенов может занять от 50 до 100 мс.
Когда дело доходит до системы кэширования, существует множество блокировок, позволяющих избежать ошибок, но накладные расходы по-прежнему незначительны — я проводил эксперименты с 100 тысячами последовательных вставок, и время на каждый элемент составляло всего несколько миллисекунд (преобладал косинусоидальный подобие). Если вашим узким местом является вычисление косинусного подобия (около 4 мс для 100 тыс. элементов), рассмотрите возможность перемещения векторов в матрицу для непрерывной памяти и/или используйте логические вложения с расстоянием Хэмминга (оператор XOR, ускорение на порядок).
В целом система работает быстрее, чем необходимо для обычных рабочих нагрузок с тысячами кэшированных элементов. Если ваши полезные данные большие (рассмотрите возможность замены на диск), у вас больше шансов столкнуться с проблемами со сборщиком мусора и памятью, чем с ограничениями вычислительных ресурсов. Помните, что цель состоит в том, чтобы предотвратить вызовы API, которые занимают от 1 до 20 секунд!
Как измерить время, необходимое для выполнения X?
Взгляните на приведенные ниже фрагменты примеров — определите время, какая часть вас интересует.
sem_cache = SemanticCache ()
# First argument: the key must always match exactly, eg, model, temperature, etc
# Second argument: the input text to be compared with the cache, can be fuzzy matched
item = sem_cache ( " key1 " , " say hi! " ; verbose = 1 ) # notice the verbose flag it can 0,1,2 for different level of detail
if ! isvalid (item)
@info " cache miss! "
item . output = " expensive result X "
# Save the result to the cache for future reference
push! (sem_cache, item)
end
Только внедрение (для настройки порога min_similarity
или времени внедрения)
using SemanticCaches . FlashRank : embed
using SemanticCaches : EMBEDDER
@time res = embed (EMBEDDER, " say hi " )
# 0.000903 seconds (104 allocations: 19.273 KiB)
# see res.elapsed or res.embeddings
# long inputs (split into several chunks and then combining the embeddings)
@time embed (EMBEDDER, " say hi " ^ 1000 )
# 0.032148 seconds (8.11 k allocations: 662.656 KiB)
Как установить порог min_similarity
?
Вы можете установить порог min_similarity
, добавив kwarg active_cache("key1", input; verbose=2, min_similarity=0.95)
.
Значение по умолчанию — 0,95, что является очень высоким порогом. Для практических целей я бы рекомендовал ~0,9. Если вы ожидаете опечаток, вы можете пойти еще немного ниже (например, 0,85).
Предупреждение
Будьте осторожны с порогами сходства. Трудно хорошо встроить суперкороткие эпизоды! Возможно, вы захотите настроить порог в зависимости от длины ввода. Всегда проверяйте их своими данными!
Если вы хотите вычислить косинусное подобие, не забудьте сначала normalize
вложения или разделить скалярное произведение на нормы.
using SemanticCaches . LinearAlgebra : normalize, norm, dot
cosine_similarity = dot (r1 . embeddings, r2 . embeddings) / ( norm (r1 . embeddings) * norm (r2 . embeddings))
# remember that 1 is the best similarity, -1 is the exact opposite
Вы можете сравнить различные входные данные, чтобы определить лучший порог для ваших вариантов использования.
emb1 = embed (EMBEDDER, " How is it going? " ) |> x -> vec (x . embeddings) |> normalize
emb2 = embed (EMBEDDER, " How is it goin'? " ) |> x -> vec (x . embeddings) |> normalize
dot (emb1, emb2) # 0.944
emb1 = embed (EMBEDDER, " How is it going? " ) |> x -> vec (x . embeddings) |> normalize
emb2 = embed (EMBEDDER, " How is it goin' " ) |> x -> vec (x . embeddings) |> normalize
dot (emb1, emb2) # 0.920
Как это отладить?
Включите подробное ведение журнала, добавив kwarg verbose = 2
, например, item = active_cache("key1", input; verbose=2)
.
[ ] Срок действия кэша на основе времени [ ] Ускорьте процесс внедрения / рассмотрите возможность предварительной обработки входных данных [ ] Встроенная интеграция с PromptingTools и схемами API