SemanticCaches.jl es una implementación muy ingeniosa de un caché semántico para aplicaciones de IA para ahorrar tiempo y dinero con solicitudes repetidas. No es particularmente rápido, porque estamos tratando de evitar llamadas a la API que pueden tardar incluso 20 segundos.
Tenga en cuenta que estamos utilizando un modelo BERT pequeño con un tamaño de fragmento máximo de 512 tokens para proporcionar incorporaciones locales rápidas que se ejecutan en una CPU. Para oraciones más largas, las dividimos en varios fragmentos y consideramos su incrustación promedio, ¡pero úsala con cuidado! La latencia puede dispararse y volverse peor que simplemente llamar a la API original.
Para instalar SemanticCaches.jl, simplemente agregue el paquete usando el administrador de paquetes de 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
El objetivo principal de crear este paquete era almacenar en caché costosas llamadas API a modelos GenAI.
El sistema ofrece coincidencia exacta (más rápida, HashCache
) y búsqueda de similitud semántica (más lenta, SemanticCache
) de entradas STRING. Además, todas las solicitudes se comparan primero en una "clave de caché", que presenta una clave que siempre debe coincidir exactamente para que las solicitudes se consideren intercambiables (por ejemplo, mismo modelo, mismo proveedor, misma temperatura, etc.). Debe elegir la clave de caché y la entrada adecuadas según su caso de uso. Esta opción predeterminada para la clave de caché debe ser el nombre del modelo.
¿Qué sucede cuando llamas al caché (proporcionas cache_key
y string_input
)?
cache.items
.cache_key
para encontrar índices de los elementos correspondientes en items
. Si no se encuentra cache_key
, devolvemos CachedItem
con un campo output
vacío (es decir, isvalid(item) == false
).string_input
usando un pequeño modelo BERT y normalizamos las incorporaciones (para que sea más fácil comparar la distancia del coseno más adelante).min_similarity
, devolvemos el elemento almacenado en caché (la salida se puede encontrar en el campo item.output
). Si no hemos encontrado ningún elemento almacenado en caché, devolvemos CachedItem
con un campo output
vacío (es decir, isvalid(item) == false
). Una vez que calcule la respuesta y la guarde en item.output
, puede enviar el elemento al caché llamando push!(cache, item)
.
Según su conocimiento de las llamadas API realizadas, necesita determinar: 1) la clave de caché (almacenamiento separado de elementos almacenados en caché, por ejemplo, diferentes modelos o temperaturas) y 2) cómo descomprimir la solicitud HTTP en una cadena (por ejemplo, desenvolver y unirse al contenido del mensaje formateado para la API OpenAI).
A continuación se ofrece un breve resumen de cómo puede utilizar SemanticCaches.jl con 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!
También puedes usarlo para incrustaciones, por ejemplo,
@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
Puede eliminar la capa de caché llamando HTTP.poplayer!()
(y agregarla nuevamente si realizó algunos cambios).
Puede sondear el caché llamando MyCache.SEM_CACHE
(por ejemplo, MyCache.SEM_CACHE.items[1]
).
¿Cómo es la actuación?
La mayor parte del tiempo se dedicará a 1) pequeñas incrustaciones (para textos grandes, por ejemplo, miles de tokens) y al cálculo de similitud de cosenos (para cachés grandes, por ejemplo, más de 10.000 elementos).
Como referencia, incrustar textos más pequeños, como preguntas, solo lleva unos milisegundos. Incrustar 2000 tokens puede llevar entre 50 y 100 ms.
Cuando se trata del sistema de almacenamiento en caché, hay muchos bloqueos para evitar fallas, pero la sobrecarga sigue siendo insignificante: realicé experimentos con 100.000 inserciones secuenciales y el tiempo por elemento fue de solo unos pocos milisegundos (dominado por la similitud del coseno). Si su cuello de botella está en el cálculo de similitud del coseno (c. 4 ms para 100k elementos), considere mover vectores a una matriz para memoria continua y/o usar incrustaciones booleanas con distancia de Hamming (operador XOR, c. aceleración de orden de magnitud).
En definitiva, el sistema es más rápido de lo necesario para cargas de trabajo normales con miles de elementos almacenados en caché. Es más probable que tenga problemas de memoria y GC si sus cargas útiles son grandes (considere cambiarlas al disco) que enfrentar límites de computación. Recuerde que la motivación es evitar llamadas a la API que tardan entre 1 y 20 segundos.
¿Cómo medir el tiempo que se tarda en hacer X?
Eche un vistazo a los fragmentos de ejemplo a continuación: cronometre la parte que le interese.
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
Solo incrustación (para ajustar el umbral min_similarity
o cronometrar la incrustación)
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)
¿Cómo establecer el umbral min_similarity
?
Puede establecer el umbral min_similarity
agregando kwarg active_cache("key1", input; verbose=2, min_similarity=0.95)
.
El valor predeterminado es 0,95, que es un umbral muy alto. Para fines prácticos, recomendaría ~0,9. Si espera algunos errores tipográficos, puede bajar incluso un poco más (por ejemplo, 0,85).
Advertencia
Tenga cuidado con los umbrales de similitud. ¡Es difícil integrar bien secuencias súper cortas! Es posible que desee ajustar el umbral según la duración de la entrada. Pruébalos siempre con tus insumos!!
Si desea calcular la similitud del coseno, recuerde normalize
primero las incrustaciones o dividir el producto escalar por las 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
Puede comparar diferentes entradas para determinar el mejor umbral para sus 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
¿Cómo depurarlo?
Habilite el registro detallado agregando kwarg verbose = 2
, por ejemplo, item = active_cache("key1", input; verbose=2)
.
[ ] Validez de la caché basada en el tiempo [ ] Acelere el proceso de incrustación/considere el preprocesamiento de las entradas [ ] Integración nativa con PromptingTools y los esquemas API