pexels-googledeepmind-18069813.jpg

Un Chatbot de IA en Google Chat que consulta nuestros documentos y aprende del Equipo

Guillem

10 min de lectura

Introducción y objetivos

Desde que empezó todo el boom de la inteligencia artificial —primero con ChatGPT y luego con los modelos que han ido apareciendo— en nuestro equipo también nos hicimos la misma pregunta que muchos: ¿cómo podríamos aprovechar esa capacidad, no solo con el conocimiento que ya tiene el modelo, sino también con nuestra propia información interna? Queríamos una forma de acceder fácilmente al conocimiento que vamos generando en el día a día —documentación, aprendizajes, errores pasados— sin tener que abrir mil herramientas o buscadores.

Bartolo

Este proyecto nace precisamente de ahí. Queríamos algo práctico, integrado en nuestro entorno de trabajo (en este caso, Google Chat), que nos permitiera preguntar cosas sobre nuestros propios documentos, y además ir alimentando esa base de conocimiento directamente desde las conversaciones. Es una solución sencilla, pero creemos que puede aplicarse fácilmente a muchos otros equipos que se encuentren con el mismo problema.

Bartolo-Ubiqual.png

Menciono a Gonzalo porque hemos trabajado juntos en bastantes proyectos, si necesitas Agencia de Marketing y Comunicación, echa un ojo a Ubiqual

Un último objetivo pero que nos parecía muy importante, era hacer todo esto de una forma sostenible. Hay mucha documentación y ejemplos sobre cómo hacer chatbots con IA e interacción interna que parten de infraestructuras muy complejas y caras. Funcionan, pero no nos parecía que pudieran ser sostenibles ni que de verdad se fueran a usar con los costes que implicaban. Así que planteamos la mínima infraestructura posible para cumplir estos objetivos. 

Termino pidiendo perdón si alguien que lee esto se llama Bartolo 😅. No tengo claro por qué ese nombre, pero es así como decidí llamar a nuestro Bot interno. Supongo que SoftBot, SoftspringBot o nombres así me parecían sosísimos... podría haber ido por lo moderno y llamarle Leaves, o algo así verdoso o primaveral sin que fuera un nombre existente, pero en fin, se llama Bartolo de momento, a falta de mejor nombre. (¡¡Se aceptan sugerencias!! 😅)

Cómo lo hemos montado

Una alternativa a esto es utilizar un proveedor con un servicio de RAG (Retrieval Augmented Generation), que los hay que te integran el chatbot directamente, te da un endpoint de tu chatbot con el código para meterlo en la web y tienes un panel de control al que subir los documentos, pero nosotros queríamos algo integrado con nuestro Google Chat y, aparte, queríamos hacerlo nosotros. A nivel de coste la diferencia es brutal. 

El sistema está compuesto por varios bloques conectados:

Diagrama Bartolo.png

Aunque parezcan muchas piezas diferentes, vamos a ver que es bastante sencillo en realidad. Vamos paso por paso desde arriba a la izquierda. 

El Cloud Scheduler es un producto de Google Cloud (todo lo montamos en Google Cloud, pero hay productos equivalentes en AWS o Azure, claro), que permite hacer llamadas periódicas a un servicio. En este caso y, de momento, lo hemos configurado para que, una vez a la semana, llame a la función que hemos llamado en el diagrama, Documents.

La función Documents es un script de Python que comprueba una carpeta de Google Drive, para ver si hay ficheros nuevos o los que hay han sido actualizados. En caso de que haya algo nuevo, llama a un modelo de Gemini para generar los embeddings de estos ficheros y los almacena en una tabla de una base de datos PostgreSQL en Cloud SQL. 

Si, el párrafo anterior es denso, igual da para otro artículo más adelante, de momento la idea es que troceamos los ficheros de texto y sacamos de los mismos la información que luego nos va a permitir hacer búsquedas para encontrar contenido similar o relacionado, de entre todo lo que tenemos. Toda esa información la guardamos en una base de datos en Cloud SQL. 

Por otro lado, tenemos un script muy sencillo en App Script de Google que hace las veces del Bot, Bartolo. Ese script contiene los métodos que reaccionan cuando se le incluye en un espacio nuevo o cuando se le manda un mensaje.

Cuando recibe un mensaje, llama a la Cloud Function que hemos llamado aquí Chat. Esa función distingue cuando le pedimos información de cuando le estamos pidiendo que guarde algo. En el primer caso busca coincidencias semánticas en la base de datos de Cloud SQL, si las hay se las pasa como contexto a un modelo de Gemini. En el segundo caso, genera los embeddings como hicimos en el caso de los ficheros de Google Drive, y los almacena en Cloud SQL para las siguientes consultas. 

Cómo funciona cada parte a nivel funcional

Una idea que quiero transmitir con el artículo es que esto que hemos hecho de esta forma, se podría haber hecho de muchas otras formas, y por eso además de explicar cómo lo hemos hecho nosotros, quiero explicar en este apartado qué componentes, a nivel funcional, teníamos que tener, y por qué hemos elegido nosotros estos, y qué otros se podrían elegir, por si en tu caso aplican más otros o habías pensado hacerlo con otros. 

Procesamiento de Documentos: Necesitábamos algo que preparase los documentos. Para hacer RAG (Retrieval Augmented Generation) que es esto que estamos haciendo de pasar información propia al modelo de IA generativa, una forma es pre-procesar los documentos, generar embeddings de los mismos y guardarlos para poder hacer luego consultas semánticas (buscar las similitudes con lo que el usuario está escribiendo y, esas similitudes encontradas, pasárselas a un modelo de IA generativa para que genere la respuesta).

La otra idea es utilizar modelos modernos con contextos muy amplios y pasar toda nuestra información como contexto. Esto, que se explica por ejemplo en este artículo (en inglés), tiene dos problemas fundamentales, a nivel de coste le pasamos TODO lo que tenemos en cada llamada al modelo generativo, es un despilfarro. Por otro lado, es posible que por grande que sea el contexto, en un momento dado lleguemos a las limitaciones físicas del mismo, y esto ya no nos serviría (ahora hay modelos como el de magic.dev con contextos hasta de 100M de tokens, que viene a ser como 100 millones de sílabas, es difícil llegar al límite, pero sería un coste tremendo pasarle eso en cada llamada al bot, p.ej). 

A nivel de con qué máquina o cómo hacerlo, hay muchas propuestas basadas en un Cluster de Kubernetes en el que podemos tener contenedores con las distintas partes. A nivel económico esto implica una serie de máquinas funcionando 24/7 y, al menos en nuestro caso, era un poco excesivo, por eso hemos optado por Cloud Functions, que tienen una pequeña latencia (tardan un poco más, porque no están preparadas todo el rato, es como si hubiera que arrancarlas cada vez), pero solo se pagan por uso. 

En este mismo sentido, los documentos hemos optado por guardarlos en Google Drive por comodidad, técnicamente este punto sería muy parecido con cualquier otra forma de almacenamiento. Si que hemos elegido un Cloud Scheduler que llama una vez a la semana y miramos si hay cambios porque es muy sencillo de configurar y de mantener separado (si queremos mirar más a menudo, basta cambiar la configuración del Cloud Scheduler).

También podría hacerse al revés, que Google Drive nos notificara cuando hay algún cambio, pero es un poco más complejo y no es tan fácil cambiar entre un proveedor y otro (me refiero a que lo que hemos hecho es muy fácil de adaptar a otro almacenamiento, un Bucket en S3 o un Space en DigitalOcean, por ejemplo), al revés no lo sería.

Base de datos para los embeddings: Necesitábamos guardar estos vectores, embeddings, relacionados con el contenido que hemos sacado de los PDF's, pero también luego tendremos que hacerlo para las cosas que le pidamos al Bot que guarde. Hay muchas bases de datos específicas para IA, como Pinecone, Qdrant, Weaviate, Chroma, etc. Algunas son OpenSource y otras se usan como servicio. 

Dentro de la plataforma de Google Cloud, está el producto AlloyDB y, otra alternativa, es usar Cloud SQL, el producto estándar de bases de datos, con una base de datos PostgreSQL (opensource y muy estándar en el mundo del desarrollo), que tiene una extensión llamada pgvector que le permite guardar vectores y hacer búsquedas semánticas. 

No es nuestra idea hacer aquí una comparativa de todas, pero si quisiéramos usar Qdrant por ejemplo, open source, en Google Cloud, tendríamos que montar bien una máquina para esto en GCE o similar, o meterla como contenedor de Kubernetes (en la arquitectura que comentábamos antes de hacerlo todo en un Cluster). La forma más económica es con la alternativa que hemos elegido, la máquina más pequeña posible, con PostgreSQL y pgvector, unos 7$ al mes. 

A nivel de rendimiento, para volúmenes grandes y mucha concurrencia, funcionan mejor las bases de datos creadas específicamente para esto, pero son diferencias que necesitan de un caso de uso muy concreto para merecer la pena.

Integración con Google Chat: Aqui ocurre algo parecido al caso de la función para procesar los documentos. Podríamos haber hecho un despliegue en un contenedor de Kubernetes. La solución que habríamos elegido en caso de montar una base de datos Qdrant, por ejemplo, y tendríamos un cluster con todos los contenedores, el que procesa documentos, la base de datos de vectores y el contenedor que procesa el chat, pero como no siempre están en uso, nos parecía demasiado para este caso de uso, así que hemos optado también por una función en Cloud Functions en Python, muy similar a la otra. 

Para la integración propiamente dicha, Google Chat permite integración con endpoints directamente (haciendo llamadas a una URL cuando hay un mensaje), y ese endpoint podría ser nuestra función o nuestro contenedor. Hemos decidido hacerlo con App Script por simplicidad, así el mismo script gestiona todas las posibles llamadas del chat (desde Google chat se llama a distintas funciones cuando hay un mensaje, cuando se agrega al Bot a un espacio nuevo, cuando se le elimina del espacio, etc). 

Vale, y ¿Cuánto cuesta esto?

Vamos a la panoja, que al final todo esto de la IA está muy bien, pero hay que pagarlo. 

Las Cloud Functions tienen una capa gratuita de 240,000 vCPU-seconds por mes, nuestras funciones usan 1vCPU y de media tardan unos 4-5s (la de chat, la otra tarda bastante más pero la estamos ejecutando una vez por semana), serían unas 48.000 llamadas gratuitas al mes. Hay que tener en cuenta que esto se agrega por billing_account en Google, si tenéis muchos proyectos con el mismo billing account y varios utilizan Cloud Functions, la capa gratuita se repartirá entre ellos. 

La llamada del Cloud Scheduler entra dentro de la capa gratuita, al ser una vez a la semana. 

La instancia de Cloud SQL que hemos montado es la más barata posible, una instancia compartida, sin redundancia, con el modelo Enterprise, db-f1-micro, unos 6-7$ al mes. 

En cuanto a Gemini, ahora la capa gratuita está limita en llamadas por día, en concreto en el modelo experimental que usamos para los embeddings, en 100 llamadas al día, pero en cuanto tienes una cuenta de pago asociada al proyecto, el tier-1 son 1000 llamadas al día. Se pueden ver todos precios aqui. Claramente con el uso es la parte que más puede subir de precio, pero también se pueden ajustar mucho los modelos a utilizar, actualizaremos este artículo más adelante cuando tengamos detalles de coste mensual. 

Un poco de detalle del código

No sería compartir si no explicásemos más en detalle cómo hacerlo.

Un tema sobre la seguridad, nosotros lo hemos hecho todo de tal forma que una cuenta de servicio del proyecto es la que tiene acceso a las distintas funciones de Cloud Functions, a la base de datos, etc. Es decir, nada es accesible desde fuera, a todo se accede desde una cuenta de servicio. 

Por otro lado, a nivel de código, todas las claves, tanto de la base de datos, como el API key de Gemini, etc, están almacenadas como secretos en el Google Secret Manager y la función accede a ellas a través de la cuenta de servicio, así no están en el código, y hay un método: 

def access_secret(secret_id):
    project_id = OUR_PROJECT
    name = f"projects/{project_id}/secrets/{secret_id}/versions/latest"
    secret_client = secretmanager.SecretManagerServiceClient()
    response = secret_client.access_secret_version(name=name)
    return response.payload.data.decode("UTF-8")

Que devuelve un secreto a partir de su id. Esto se usa luego en un método de setup de la función: 

def setup():
    logging.info("Setting up environment...")

    # Load secrets
    drive_folder_id = access_secret("DRIVE_FOLDER_ID")
    pg_user = access_secret("PG_USER")
    pg_password = access_secret("PG_PASSWORD")
    pg_database = access_secret("PG_DATABASE")
    pg_host = access_secret("PG_HOST")
    gemini_api_key = access_secret("GEMINI_API_KEY")

    # Initialize GenAI client
    genai_client = genai.Client(api_key=gemini_api_key)

    # Initialize Drive client
    credentials, _ = default(scopes=['https://www.googleapis.com/auth/drive.readonly'])
    drive_service = build('drive', 'v3', credentials=credentials)

luego la función que busca los ficheros y genera los embeddings sería: 

def poll_drive_and_update_embeddings(request=None):
    try:
        env = setup()
# asignamos las variables del setup
        ...

# buscamos los ficheros PDF de la carpeta de Google Drive
        query = f"'{drive_folder_id}' in parents and mimeType='application/pdf'"
        results = drive_service.files().list(
            q=query,
            fields="files(id, name, modifiedTime)",
            supportsAllDrives=True,
            includeItemsFromAllDrives=True,
        ).execute()
        files = results.get('files', [])
        
# Para cada fichero,si la fecha de actualización es posterior a 
# la última que tenemos guardada, borramos los embeddings que teníamos 
# y los generamos de nuevo
        for file in files:
            file_id = file['id']
            name = file['name']
            modified_time = date_parser.parse(file['modifiedTime'])
        
            with pool.connect() as db_conn:
                row = db_conn.execute(
                    sqlalchemy.text("SELECT MAX(created_at) FROM documents WHERE file_id = :file_id"),
                    {"file_id": file_id}
                ).fetchone()
                last_processed_time = row[0]

                if last_processed_time and last_processed_time >= modified_time:
                    logging.info(f"Skipping unchanged file: {name}")
                    continue

                db_conn.execute(
                    sqlalchemy.text("DELETE FROM documents WHERE file_id = :file_id"),
                    {"file_id": file_id}
                )
                db_conn.commit()

# Esta es la parte que descarga un fichero, trocea el mismo en partes y para cada
# parte genera los embeddings con una llamada al modelo de Gemini

                pdf_data = download_file(drive_service, file_id)
                text = extract_text_from_pdf(pdf_data)
                chunks = split_text_into_chunks(text)

                for i, chunk in enumerate(chunks):
                    embedding = generate_embedding(genai_client, chunk)
                    embedding_str = to_pgvector_string(embedding)
                    db_conn.execute(
                        sqlalchemy.text("""
                            INSERT INTO documents (file_id, file_name, chunk_index, content, embedding)
                            VALUES (:file_id, :file_name, :chunk_index, :content, :embedding)
                            """),
                        {
                            "file_id": file_id,
                            "file_name": name,
                            "chunk_index": i,
                            "content": chunk,
                            "embedding": embedding_str
                        }
                    )

                db_conn.commit()

        connector.close()
        return "Success", 200

    except Exception as e:
        logging.exception("An error occurred during execution.")
        return f"Error: {str(e)}", 500

Dentro de esa función, hay una llamada a generate_embeddings, que sería así: 

def generate_embedding(client, text: str) -> list[float]:
    response = client.models.embed_content(
        model="gemini-embedding-exp-03-07",
        contents=text,
        config=types.EmbedContentConfig(
            task_type="RETRIEVAL_DOCUMENT",
            output_dimensionality=768,
        ),
    )
    return response.embeddings[0].values

A medida que vayan surgiendo nuevos modelos habría que ir cambiando el modelo (esto podría haber sido otra variable de entorno o un secret). También hemos fijado el output a 768. Este modelo por defecto genera vectores de 3072 dimensiones. En nuestras pruebas, para este caso de uso, con 768 es suficiente. Solo hay tres valores posibles, en este modelo, 3072(por defecto),1536 o 768. 

La definición que hagamos de la base de datos, en nuestro caso: 

CREATE TABLE documents (
    id SERIAL PRIMARY KEY,
    file_id TEXT,
    file_name TEXT,
    chunk_index INT,
    content TEXT,
    embedding VECTOR(768),
    created_at TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE(file_id, chunk_index)
);

Tiene que ser coherente, claro, con esa definición del tamaño de los vectores. 

Conclusiones

Bartolo es un ejemplo práctico de cómo aplicar IA de forma útil y concreta en el día a día de un equipo. No se trata de una gran solución genérica, sino de una integración específica que resuelve necesidades reales: acceder rápidamente a conocimiento interno y mantenerlo vivo desde las conversaciones habituales del equipo. Hemos adaptado componentes nuevos de IA a un entorno que ya conocíamos, para crear una herramienta ligera, integrada y útil. Creemos que este tipo de soluciones pueden inspirar a otros equipos a construir sobre lo que ya tienen, con una capa de inteligencia que realmente les aporte valor.

📫
Hasta aquí el artículo de hoy. ¡Si quieres puedes escribirnos por redes sociales como siempre, o a hola@softspring.es con cualquier duda o sugerencia!

¡Trabajemos juntos!

¿Quieres contarnos tu idea?

CONTÁCTANOS