SemanticCaches.jl est une implémentation très hacky d'un cache sémantique pour les applications d'IA permettant d'économiser du temps et de l'argent avec des requêtes répétées. Ce n'est pas particulièrement rapide, car nous essayons d'empêcher les appels d'API qui peuvent prendre même 20 secondes.
Notez que nous utilisons un petit modèle BERT avec une taille de bloc maximale de 512 jetons pour fournir des intégrations locales rapides exécutées sur un processeur. Pour les phrases plus longues, nous les divisons en plusieurs morceaux et considérons leur intégration moyenne, mais à utiliser avec précaution ! La latence peut monter en flèche et devenir pire que le simple appel de l'API d'origine.
Pour installer SemanticCaches.jl, ajoutez simplement le package à l'aide du gestionnaire de packages 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
L'objectif principal de la création de ce package était de mettre en cache les appels d'API coûteux vers les modèles GenAI.
Le système offre une correspondance exacte (plus rapide, HashCache
) et une recherche de similarité sémantique (plus lente, SemanticCache
) des entrées STRING. De plus, toutes les demandes sont d'abord comparées sur une « clé de cache », qui présente une clé qui doit toujours correspondre exactement pour que les demandes soient considérées comme interchangeables (ex : même modèle, même fournisseur, même température, etc.). Vous devez choisir la clé de cache et l'entrée appropriées en fonction de votre cas d'utilisation. Ce choix par défaut pour la clé de cache doit être le nom du modèle.
Que se passe-t-il lorsque vous appelez le cache (fournissez cache_key
et string_input
) ?
cache.items
.cache_key
est recherché pour trouver les indices des éléments correspondants dans items
. Si cache_key
n'est pas trouvé, nous renvoyons CachedItem
avec un champ output
vide (c'est-à-dire isvalid(item) == false
).string_input
à l'aide d'un petit modèle BERT et normalisons les intégrations (pour faciliter la comparaison ultérieure de la distance cosinus).min_similarity
, nous renvoyons l'élément mis en cache (la sortie peut être trouvée dans le champ item.output
). Si nous n'avons trouvé aucun élément en cache, nous renvoyons CachedItem
avec un champ output
vide (c'est-à-dire isvalid(item) == false
). Une fois que vous avez calculé la réponse et l'avez enregistrée dans item.output
, vous pouvez pousser l'élément vers le cache en appelant push!(cache, item)
.
En fonction de votre connaissance des appels API effectués, vous devez déterminer : 1) la clé de cache (stockage séparé des éléments mis en cache, par exemple différents modèles ou températures) et 2) comment décompresser la requête HTTP dans une chaîne (par exemple, déballer et rejoindre le contenu du message formaté pour l'API OpenAI).
Voici un bref aperçu de la façon dont vous pouvez utiliser SemanticCaches.jl avec 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!
Vous pouvez également l'utiliser pour des intégrations, par exemple :
@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
Vous pouvez supprimer la couche de cache en appelant HTTP.poplayer!()
(et l'ajouter à nouveau si vous avez apporté des modifications).
Vous pouvez sonder le cache en appelant MyCache.SEM_CACHE
(par exemple, MyCache.SEM_CACHE.items[1]
).
Comment se passe la prestation ?
La majorité du temps sera consacrée à 1) de minuscules intégrations (pour les textes volumineux, par exemple des milliers de jetons) et au calcul de la similarité cosinus (pour les grands caches, par exemple plus de 10 000 éléments).
Pour référence, l'intégration de textes plus petits, comme des questions, ne prend que quelques millisecondes. L'intégration de 2 000 jetons peut prendre entre 50 et 100 ms.
En ce qui concerne le système de mise en cache, il existe de nombreux verrous pour éviter les erreurs, mais la surcharge reste négligeable : j'ai mené des expériences avec 100 000 insertions séquentielles et le temps par élément n'était que de quelques millisecondes (dominé par la similarité cosinus). Si votre goulot d'étranglement réside dans le calcul de similarité cosinus (environ 4 ms pour 100 000 éléments), envisagez de déplacer les vecteurs dans une matrice pour une mémoire continue et/ou utilisez des incorporations booléennes avec une distance de Hamming (opérateur XOR, environ une accélération de l'ordre de grandeur).
Dans l’ensemble, le système est plus rapide que nécessaire pour les charges de travail normales avec des milliers d’éléments mis en cache. Vous êtes plus susceptible d'avoir des problèmes de GC et de mémoire si vos charges utiles sont volumineuses (envisagez de les échanger sur le disque) plutôt que de faire face à des limites de calcul. N'oubliez pas que la motivation est d'empêcher les appels d'API qui durent entre 1 et 20 secondes !
Comment mesurer le temps nécessaire pour faire X ?
Jetez un œil aux exemples d'extraits ci-dessous - chronométrez la partie qui vous intéresse.
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
Intégration uniquement (pour régler le seuil min_similarity
ou pour chronométrer l'intégration)
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)
Comment définir le seuil min_similarity
?
Vous pouvez définir le seuil min_similarity
en ajoutant le kwarg active_cache("key1", input; verbose=2, min_similarity=0.95)
.
La valeur par défaut est 0,95, ce qui constitue un seuil très élevé. Pour des raisons pratiques, je recommanderais ~0,9. Si vous vous attendez à des fautes de frappe, vous pouvez aller encore un peu plus bas (par exemple, 0,85).
Avertissement
Soyez prudent avec les seuils de similarité. Difficile de bien intégrer des séquences super courtes ! Vous souhaiterez peut-être ajuster le seuil en fonction de la longueur de l'entrée. Testez-les toujours avec vos entrées !!
Si vous souhaitez calculer la similarité cosinus, n'oubliez pas de normalize
d'abord les plongements ou de diviser le produit scalaire par les normes.
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
Vous pouvez comparer différentes entrées pour déterminer le meilleur seuil pour vos cas d'utilisation
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
Comment le déboguer ?
Activez la journalisation détaillée en ajoutant le kwarg verbose = 2
, par exemple, item = active_cache("key1", input; verbose=2)
.
[ ] Validité du cache basée sur le temps [ ] Accélérer le processus d'intégration / envisager de prétraiter les entrées [ ] Intégration native avec PromptingTools et les schémas API