Open WebUI citations: citas y fuentes en funciones de tipo pipe (pipe functions)

En Open WebUI las funciones de tipo pipe (https://docs.openwebui.com/features/plugin/functions/pipe/) se comportan como si fueran un nuevo modelo disponible. La documentación de Open WebUI explica cómo hacerlo, y además algunos ejemplos compartidos por la comunidad están disponibles (https://openwebui.com/functions).

Sin embargo, no está muy detallado ni claro cómo añadir a una función de tipo pipe las citas o fuentes usadas cuando se usa una base de conocimiento formada por un conjunto de archivos (Knowledge) usual de un sistema RAG, tal como lo hace Open WebUi de forma nativa, es decir en su propio chat (usando un modelo LLM cualquiera)

Tras consultar algunos enlaces (https://github.com/open-webui/pipelines/discussions/156, https://github.com/open-webui/pipelines/issues/229) y tras varias pruebas, a continuación se describe cómo hacerlo con un sencillo ejemplo, en el que se usa una function de tipo pipe simplemente para efectuar la consulta al LLM.

Estructura de tipo citation

Open WebUI a la hora de emitir en su interfaz un mensaje de tipo citation espera una estructura de datos con los siguientes campos:

{
                "type": "citation",
                "data": {
                    "document": [
                        "chunk1",
                        "chunk2 ",
                        "chunk3",
                        ],
                    "metadata": [
                        {
                            "source": "test1.txt",
                            "name": "TEST1.txt",
                        },
                        {
                            "source": "test1.txt",
                            "name": "TEST1.txt",
                        },
                        {
                            "source": "test1.txt",
                            "name": "TEST1.txt",
                        },
                    ],
                    "source": {
                        "name": "TEST1.txt",
                        "url": "http://127.0.0.1:8080/api/v1/files/FILE_ID/content",
                    },
                    "distances": [0.1, 0.567, 0.012],
                },
            }

Con eso, y con la respuesta que genera la llamada al LLM en la función pipe a la API de Open WebUI (chat_completion() o generate_chatcompletion() que son lo mismo) ya puede implementarse una función para parsear la respuesta y ponerla con la estructura de objeto citations.

La respuesta de la llamada al LLM contiene una estructura sources, en donde se incorpora toda la información de las fuentes, los chunks de texto, y las similitudes que se han incluido en el contexto para la llamada al LLM, extraidas del conjunto de archivos del Knowledge o RAG.

Este es un ejemplo muy sencillo de la respuesta de la llamada al LLM, en donde se aprecia que la estructura sources contien en campos separados el chunk de texto, metadatos (nombre de archivo) y la similitud obtenida en la búsqueda.

{
  "sources": [
    {
      "source": {
        "type": "collection",
        "id": "cf810520-497e-4337-a238-98cd6983e1c1"
      },
      "document": [
        "Blue para cumplir los límites ambientales.\n",
        " la combustión, los diésel la logran mediante compresión. Esta diferencia técnica se traduce en ventajas y desventajas particulares. Los motores diésel son más eficientes, consumen menos combustible y producen más par motor; en cambio, los de gasolina son más ligeros, ofrec",
        "ispa generada por la bujía. Esta explosión provoca la expansión de los gases, que empujan el pistón y generan el movimiento del cigüeñal. El ciclo se repite de manera continua, transformando la energía química del combustible en energía mecánica aprovechable",
        " del vehículo. Las pruebas en condiciones reales (RDE) introducidas por la Unión Europea buscan reducir la brecha entre los valores de laboratorio y los que se producen en carretera. Además, la progresiva electrificación del parque automovilístico y las medidas de zonas",
        "ivos: monóxido de carbono (CO), hidrocarburos (HC), óxidos de nitrógeno (NOx) y partículas. Para cumplir con estas exigencias, los motores diésel incorporan filtros de partículas (DPF) y sistemas de reducción catal"
      ],
      "metadata": [
        {
          "hash": "efab7587c6b7c3c0e966fc1e20840bb073e66e0d764e3f14b3d065d5e29391fd",
          "created_by": "8e94e1b0-ed6f-4a86-8a77-efb6f24708af",
          "embedding_config": "{'engine': '', 'model': 'sentence-transformers/all-MiniLM-L6-v2'}",
          "file_id": "5ae4b1b9-fe0b-4c41-962c-a006bc639df2",
          "start_index": 0,
          "name": "motor2.txt",
          "source": "motor2.txt"
        },
        {
          "created_by": "8e94e1b0-ed6f-4a86-8a77-efb6f24708af",
          "file_id": "904e2b1b-9f35-42d2-be92-1adff46b60c9",
          "name": "motor3.txt",
          "start_index": 0,
          "source": "motor3.txt",
          "embedding_config": "{'engine': '', 'model': 'sentence-transformers/all-MiniLM-L6-v2'}",
          "hash": "41ef6429ebf06895cf3e9bcc469a2537810ec39f7a08fcd359ec1963429adfaf"
        },
        {
          "start_index": 0,
          "created_by": "8e94e1b0-ed6f-4a86-8a77-efb6f24708af",
          "embedding_config": "{'engine': '', 'model': 'sentence-transformers/all-MiniLM-L6-v2'}",
          "name": "motor1.txt",
          "file_id": "40de75ef-44e7-463c-88a6-40d6df9e5039",
          "source": "motor1.txt",
          "hash": "88b17bafcfd9f91e7191ac6d04f65a2233044e6c4b2ce58c0dacc13176fcf671"
        },
        {
          "embedding_config": "{'engine': '', 'model': 'sentence-transformers/all-MiniLM-L6-v2'}",
          "hash": "60eb755f871cd0c6aa323e2083e5c61a9e2efdbe4d34d7fd06930554f6e28a70",
          "name": "motor4.txt",
          "file_id": "3643ddcc-2e4f-4399-bf06-b31b06fb730c",
          "source": "motor4.txt",
          "start_index": 0,
          "created_by": "8e94e1b0-ed6f-4a86-8a77-efb6f24708af"
        },
        {
          "hash": "60eb755f871cd0c6aa323e2083e5c61a9e2efdbe4d34d7fd06930554f6e28a70",
          "file_id": "3643ddcc-2e4f-4399-bf06-b31b06fb730c",
          "name": "motor4.txt",
          "start_index": 0,
          "source": "motor4.txt",
          "embedding_config": "{'engine': '', 'model': 'sentence-transformers/all-MiniLM-L6-v2'}",
          "created_by": "8e94e1b0-ed6f-4a86-8a77-efb6f24708af"
        }
      ],
      "distances": [
        0.5976739227771759,
        0.574465274810791,
        0.5582359135150909,
        0.5439484715461731,
        0.5429647564888
      ]
    }
  ],
  "id": "qwen3:0.6b-5e630ce7-b42c-4c1e-bcfa-4a052592092b",
  "created": 1761574467,
  "model": "qwen3:0.6b",
  "choices": [
    {
      "index": 0,
      "logprobs": null,
      "finish_reason": "stop",
      "message": {
        "role": "assistant",
        "content": "<think>\nOkay, let's see. The user is asking about  [...] That should cover it.\n</think>\n\n{\"tags\": [\"General\", \"Environmental Impact\", \"Efficiency Comparison\"]}"
      }
    }
  ],
  "object": "chat.completion",
  "usage": {
    "response_token/s": 25.6,
    "prompt_token/s": 70.15,
    "total_duration": 21383540600,
    "load_duration": 62519400,
    "prompt_eval_count": 1064,
    "prompt_tokens": 1064,
    "prompt_eval_duration": 15167141900,
    "eval_count": 157,
    "completion_tokens": 157,
    "eval_duration": 6133414600,
    "approximate_total": "0h0m21s",
    "total_tokens": 1221,
    "completion_tokens_details": {
      "reasoning_tokens": 0,
      "accepted_prediction_tokens": 0,
      "rejected_prediction_tokens": 0
    }
  }
}

Para enviar las citations basta con ir creándolas una a una con la estrucura anterior, que ya la interfaz de Open WebUI se encarga de agruparlas por nombre de archivo de la fuente (agrupando los chunks crrespondientes a un mismo archivo).

El siguiente código es de la función pipe completa, con las funciones auxiliares para iterar sobre la respuesta y los datos source y emitir el mensaje citation a Open WebUI, junto con la respuesta al usuario del LLM. Contiene algunos parámetros (valves) y es importante tner en cuenta que la identificación del Knlwledge ha de tenerse previamente (basta con abrir el Knowledge en la interfaz de Open WebUI y copiar la url)

import json
import re
from typing import Dict, Any, List, Optional, Callable, Awaitable
from pydantic import BaseModel, Field
from fastapi import Request

from open_webui.models.users import Users
from open_webui.main import generate_chat_completions
from open_webui.constants import TASKS
from open_webui.utils.misc import get_last_user_message_item

# --------------------------------
class User(BaseModel):
    id: str
    email: str
    name: str
    role: str
# ---------------- Pipe ----------------

NAME = "RAG Citations"

class Pipe:
    class Valves(BaseModel):
        # Modelo para clasificar y responder (puedes usar el mismo)
        MODEL_ID: str = Field(default="qwen3:0.6b", description="Modelo LLM")

        # identificador del Knowledge http://127.0.0.1:8080/workspace/knowledge/cf810520-497e-4337-a238-98cd6983e1c1
        RAG_ID: str = Field(
            default="cf810520-497e-4337-a238-98cd6983e1c1",
            description="Colección archivos/RAG",
        )

        # --- Prompt ---
        ANSWER_SYSTEM_PROMPT: str = Field(
            default=(
                "Responde EXCLUSIVAMENTE con la información disponible en el contexto de las fuentes proporcionada."
                "Responde de manera clara y detallada argumentando bien todo"
            )
        )
        # Parámetros LLM RAG
        ANSWER_TEMPERATURE: float = Field(default=0.2)
        ANSWER_TOP_K: int = Field(default=5)
        ANSWER_TOP_P: float = Field(default=0.5)

    def __init__(self):
        self.valves = self.Valves()
        self.__current_event_emitter__: Optional[Callable[[dict], Awaitable[None]]] = (
            None
        )
        self.__user__: Optional[dict] = None
        self.__model__: Optional[str] = None
        self.__request__: Optional[Request] = None

    def pipes(self):
        return [{"id": f"{NAME}-pipe", "name": f"{NAME}"}]

    # ---------- helpers mensaje y citations en UI----------
    async def emit_message(self, text: str):
        if self.__current_event_emitter__:
            await self.__current_event_emitter__(
                {"type": "message", "data": {"content": text}}
            )

    async def emit_citation(self, citations):
        if self.__current_event_emitter__:
            await self.__current_event_emitter__(citations)

    @staticmethod
    def build_citations(sources):
        """
        {
                "type": "citation",
                "data": {
                    "document": [
                        "chunk1",
                        "chunk2 ",
                        "Chunk3",
                        ],
                    "metadata": [
                        {
                            "source": "test1.txt",
                            "name": "TEST1.txt",
                        },
                        {
                            "source": "test1.txt",
                            "name": "TEST1.txt",
                        },
                        {
                            "source": "test1.txt",
                            "name": "TEST1.txt",
                        },
                    ],
                    "source": {
                        "name": "TEST1.txt",
                        "url": "http://127.0.0.1:8080/api/v1/files/file_id/content",
                    },
                    "distances": [0.1, 0.567, 0.012],
                },
            },
        """
        for src in sources:
            citations = []
            docs = src.get("document") or []  # chunks
            metas = src.get("metadata") or []
            dists = src.get("distances") or []
            # número de chunks
            n = (
                max(len(docs), len(metas), len(dists))
                if any([docs, metas, dists])
                else 0
            )
            for i in range(n):
                # --- document[i] ---
                di = docs[i] if i < len(docs) else ""

                # --- metadata[i] ---
                mdi = metas[i] if i < len(metas) else {}
                md_name = mdi.get("name")
                md_source = mdi.get("source")
                file_id = mdi.get("file_id")

                # --- distances[i] ---
                dist_i = dists[i] if i < len(dists) else None

                # --- source document
                source_obj = {"name": md_name}
                source_obj["url"] = (
                    f"http://127.0.0.1:8080/api/v1/files/{file_id}/content"
                )

                metadata_cita = [{"source": md_source, "name": md_name}]

                data = {
                    "document": [di],
                    "metadata": metadata_cita,
                    "source": source_obj,
                }
                if isinstance(dist_i, (int, float)):
                    data["distances"] = [float(dist_i)]

                citations.append({"type": "citation", "data": data})
        return citations

    # ---------- Llamada LLM  ----------
    async def _answer_with_collection(
        self,
        __request__: Request,
        user: User,
        text: str,
        collection_id: str,
    ):
        resp = ""
        form = {
            "model": self.valves.MODEL_ID,
            "messages": [
                {"role": "system", "content": self.valves.ANSWER_SYSTEM_PROMPT},
                {"role": "user", "content": text},
            ],
            "stream": False,
            "temperature": self.valves.ANSWER_TEMPERATURE,
            "top_k": self.valves.ANSWER_TOP_K,
            "top_p": self.valves.ANSWER_TOP_P,
            "meta": {
                "capabilities": {
                    "vision": True,
                    "file_upload": True,
                    "web_search": True,
                    "citations": True,
                    "status_updates": True,
                }
            },
            "files": [{"type": "collection", "id": collection_id}],
        }
       
        resp = await generate_chat_completions(__request__, form, user)
        #print(resp)
        try:
            # respuesta LLM al usuario
            txt = resp["choices"][0]["message"]["content"]

            # sources en la respuesta
            sources = resp.get("sources") or []

        except Exception:
            txt = "No se pudo generar la respuesta."
        # mostrar respuesta LLM
        await self.emit_message(txt)

        # crear citations
        chat_citations = self.build_citations(sources)

        for chat_citation in chat_citations:
            await self.emit_citation(chat_citation)

        return

    # ---------- Entry point ----------
    async def pipe(
        self,
        body: dict,
        __user__: dict,
        __request__: Request,
        __event_emitter__=None,
        __task__=None,
        __model__=None,
    ) -> str:
        self.__current_event_emitter__ = __event_emitter__
        self.__user__ = __user__
        self.__model__ = __model__
        self.__request__ = __request__

        # Usuario y texto
        user = User(**__user__)
        messages: List[Dict[str, Any]] = body.get("messages") or []
        user_msg = get_last_user_message_item(messages)
        user_text = user_msg.get("content")
        if not user_text:
            return "No hay texto de usuario."

        # Llamada al LLM y RAG (file collection)
        collection_id = self.valves.RAG_ID
        resultado = await self._answer_with_collection(
            __request__, user, user_text, collection_id
        )

        return ""

Las siguientes capturas muestran el resultado, tal como se desaba:

openwebui

Y muestra los chunks (en título del archivo tiene la url para abrirlo)

Leave a Reply

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

This site uses Akismet to reduce spam. Learn how your comment data is processed.