Allow parametrizing components without subclassing
As of now, the __init__ of the user definable components contains potentially expensive setup code. To still have access to the requirements and the name of the components, we have defined class methods
https://github.com/Quansight/ragna/blob/55f7fc5d3d13f321eebd16d3b0901ab35ac36bc7/ragna/core/_utils.py#L73-L75
https://github.com/Quansight/ragna/blob/55f7fc5d3d13f321eebd16d3b0901ab35ac36bc7/ragna/core/_components.py#L33-L39
This works well for simple cases. For realistic use cases however, the methods have to be parametrized. This is currently solved by subclassing a baseclass and putting class constants on the subclass, e.g.
https://github.com/Quansight/ragna/blob/55f7fc5d3d13f321eebd16d3b0901ab35ac36bc7/ragna/assistants/_anthropic.py#L8-L19
https://github.com/Quansight/ragna/blob/55f7fc5d3d13f321eebd16d3b0901ab35ac36bc7/ragna/assistants/_anthropic.py#L74-L86
This approach is not really scalable. For assistant base classes with a lot of options for the parameters, e.g. OllamaAssistant, we have to provide as many subclasses. In the case of Ollama, we just added some of the available models, but certainly not all. Furthermore, the field of available LLMs is ever expanding and thus if a new model comes out, users will have to wait for another Ragna release to be able to use it or are forced to write the assistant themselves.
It would be much preferable to instantiate the components like any other Python object and allow parameters being passed to it. For example,
assistant = ragna.assistants.OllamaGemma2B()
could turn into
assistant = ragna.assistants.OllamaAssistant(model="gemma:2b")
This would eliminate the need for any subclasses of the OllamAssistant provided by Ragna while at the same time automatically supports future models assuming the API stays the same.
If we want to keep the behavior of the name and the requirements being accessible before running expensive setup code, we would need to keep the classmethods, but now have to pass the same parameters to them that we would pass to __init__. That is kinda awkward:
OllamaAssistant.display_name(model="gemma:2b")
Another solution, and the one that I'm advocating here, is to explicitly put expensive setup code in a dedicated .setup() method. Meaning, the __init__ is cheap by design and thus the requirements and name functions can become regular methods. For example
class MyLocalAssistant(ragna.core.Assistant):
def __init__(self, model_name: str) -> None:
self._model_name = model_name
self._model: Callable
def requirements():
return ["torch"]
def display_name():
return f"MyLocalAssistant/{self._model_name}"
def setup():
import torch
self._model = torch.load_model()
In the regular Ragna workflow, this means no overhead for the user. We can just call .setup() at the place where we are currently call the constructor. Only if one uses the class outside of ragna.Rag() there is some new overhead with the necessity of calling .setup() manually. But I'm ok with that, given that we aren't optimizing for this UX anyway.
Assuming my proposal above is accepted, we also need to look on how the extra parameters to the components can be passed through the configuration. Right now this is a non-issue, because we can just provide an import string for the specific subclass that we want, e.g.
assistants = [
"ragna.assistants.RagnaDemoAssistant",
"ragna.assistants.OllamaGemma2B",
]
With the changes detailed above, this will need to change to
assistants = [
"ragna.assistants.RagnaDemoAssistant",
{class="ragna.assistants.OllamaAssistant", model="gemma:2b"},
]
With that we can pass the parameters to the class rather simply. But the way back, i.e. from a Python object to an entry in the configuration file, is problematic. Assuming we have an instant of MyUserDefinedAssistant, we can only determine the class correctly. We have no idea, which parameters were used to create the class. If the original instance was created from a config file, we could store the parameters under a hidden attribute, e.g. __ragna_init_kwargs__, and read that on the way out. But this is rather brittle.
As stated, this is only an issue if we want to ever serialize a config. Right now we only require this in one place: using the interactive wizard to write a configuration file
https://github.com/Quansight/ragna/blob/55f7fc5d3d13f321eebd16d3b0901ab35ac36bc7/ragna/deploy/_cli/core.py#L68
#449 is an RFD that would make the config wizard mostly obsolete. Assuming that is accepted, we might not even have an issue here.
https://github.com/Quansight/ragna/issues/572#issuecomment-2788719676 surfaced another use case for this: async setup code. Right now all the setup code happens in __init__, which cannot by defined as async. Thus, we have to fall back to a pattern as described in https://github.com/Quansight/ragna/issues/572#issuecomment-2788832802, which increases complexity and verbosity.
If we have a a setup method as part of the protocol, we could allow users to define it sync or async as needed, using the tooling we already use for the other protocol methods.