SemanticCaches.jl é uma implementação muito hackeada de cache semântico para aplicativos de IA para economizar tempo e dinheiro com solicitações repetidas. Não é particularmente rápido, porque estamos tentando evitar chamadas de API que podem levar até 20 segundos.
Observe que estamos usando um modelo BERT minúsculo com tamanho máximo de bloco de 512 tokens para fornecer incorporações locais rápidas em execução em uma CPU. Para frases mais longas, nós as dividimos em vários pedaços e consideramos sua incorporação média, mas use-a com cuidado! A latência pode disparar e se tornar pior do que simplesmente chamar a API original.
Para instalar o SemanticCaches.jl, basta adicionar o pacote usando o gerenciador de pacotes 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
O objetivo principal da construção deste pacote era armazenar em cache chamadas de API caras para modelos GenAI.
O sistema oferece correspondência exata (mais rápida, HashCache
) e pesquisa de similaridade semântica (mais lenta, SemanticCache
) de entradas STRING. Além disso, todas as solicitações são primeiro comparadas em uma “chave de cache”, que apresenta uma chave que deve sempre corresponder exatamente para que as solicitações sejam consideradas intercambiáveis (por exemplo, mesmo modelo, mesmo provedor, mesma temperatura, etc). Você precisa escolher a chave de cache e a entrada apropriadas, dependendo do seu caso de uso. Esta escolha padrão para a chave de cache deve ser o nome do modelo.
O que acontece quando você chama o cache (fornece cache_key
e string_input
)?
cache.items
.cache_key
é consultado para encontrar índices dos itens correspondentes em items
. Se cache_key
não for encontrado, retornamos CachedItem
com um campo output
vazio (ou seja, isvalid(item) == false
).string_input
usando um pequeno modelo BERT e normalizamos os embeddings (para facilitar a comparação posterior da distância do cosseno).min_similarity
, retornamos o item armazenado em cache (a saída pode ser encontrada no campo item.output
). Se não encontrarmos nenhum item em cache, retornamos CachedItem
com um campo output
vazio (ou seja, isvalid(item) == false
). Depois de calcular a resposta e salvá-la em item.output
, você pode enviar o item para o cache chamando push!(cache, item)
.
Com base no seu conhecimento das chamadas de API feitas, você precisa determinar: 1) chave de cache (armazenamento separado de itens em cache, por exemplo, diferentes modelos ou temperaturas) e 2) como descompactar a solicitação HTTP em uma string (por exemplo, desembrulhar e junte o conteúdo da mensagem formatada para API OpenAI).
Aqui está um breve resumo de como você pode usar SemanticCaches.jl com 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!
Você também pode usá-lo para incorporações, por exemplo,
@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
Você pode remover a camada de cache chamando HTTP.poplayer!()
(e adicioná-la novamente se tiver feito algumas alterações).
Você pode testar o cache chamando MyCache.SEM_CACHE
(por exemplo, MyCache.SEM_CACHE.items[1]
).
Como está o desempenho?
A maior parte do tempo será gasta em 1) pequenas incorporações (para textos grandes, por exemplo, milhares de tokens) e no cálculo da similaridade de cossenos (para caches grandes, por exemplo, mais de 10 mil itens).
Para referência, incorporar textos menores, como perguntas, leva apenas alguns milissegundos. A incorporação de 2.000 tokens pode levar de 50 a 100 ms.
Quando se trata do sistema de cache, existem muitos bloqueios para evitar falhas, mas a sobrecarga ainda é insignificante - fiz experimentos com 100k inserções sequenciais e o tempo por item foi de apenas alguns milissegundos (dominado pela similaridade de cosseno). Se o seu gargalo estiver no cálculo de similaridade de cosseno (c. 4ms para itens de 100k), considere mover vetores em uma matriz para memória contínua e/ou usar incorporações booleanas com distância de Hamming (operador XOR, c. aceleração de ordem de magnitude).
Resumindo, o sistema é mais rápido do que o necessário para cargas de trabalho normais com milhares de itens armazenados em cache. É mais provável que você tenha problemas de GC e memória se suas cargas forem grandes (considere trocar para disco) do que enfrentar limites de computação. Lembre-se de que a motivação é evitar chamadas de API que levem de 1 a 20 segundos!
Como medir o tempo que leva para fazer X?
Dê uma olhada nos trechos de exemplo abaixo - cronometre a parte em que você está interessado.
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
Somente incorporação (para ajustar o limite min_similarity
ou cronometrar a incorporação)
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)
Como definir o limite min_similarity
?
Você pode definir o limite min_similarity
adicionando kwarg active_cache("key1", input; verbose=2, min_similarity=0.95)
.
O padrão é 0,95, que é um limite muito alto. Para fins práticos, eu recomendaria ~0,9. Se você está esperando alguns erros de digitação, você pode diminuir ainda mais (por exemplo, 0,85).
Aviso
Tenha cuidado com os limites de similaridade. É difícil incorporar bem sequências supercurtas! Você pode querer ajustar o limite dependendo do comprimento da entrada. Sempre teste-os com suas informações!!
Se você deseja calcular a similaridade do cosseno, lembre-se de normalize
primeiro os embeddings ou dividir o produto escalar pelas normas.
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
Você pode comparar diferentes entradas para determinar o melhor limite para seus casos de uso
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
Como depurar isso?
Habilite o registro detalhado adicionando kwarg verbose = 2
, por exemplo, item = active_cache("key1", input; verbose=2)
.
[ ] Validade do cache baseada no tempo [ ] Acelere o processo de incorporação/considere o pré-processamento das entradas [ ] Integração nativa com PromptingTools e os esquemas de API