Archivo

Archive for the ‘LLM’ Category

Vatuta 0.3.0: Supercharging RAG with MCP Tools and hardening with Container Sandboxing

jueves, 20 agosto 2026, 10:25 Deja un comentario

1. Introduction and motivation

Vatuta already supports integration with JIRA, Slack, and Confluence sources for both the ingestion and indexing mechanisms and the query analysis mechanisms. In the first case, the events and contents of the sources are indexed in order to be collected by context and term (a hybrid approach is used). In the second case, when the user’s query is analyzed, specific data from sources may be collected and added to the context of the response, like JIRA tickets, Confluence articles, or Slack conversations.

But the context for the response may include more relevant or useful information to answer the user’s question: information retrieved from external sources, real-time data specific to the moment the question is asked, data queried on the fly, information calculated or summarized from the sources… These kinds of data are not available in the current model/architecture of the RAG, either indexed by context or by term.

In the previous version, we already supported some of these features by adding tools during the analysis of the user’s query. This analysis is a routing agent that, using the tools available to it, iterates, collecting the needed information for the context until it decides that no more information is needed or reachable. This process is dynamic, depends on the user’s question and the available tools, and performs independently without user interaction. By providing tools to this process, we allow the process to collect more information or relevant data.

The challenge is how to add tools without adding the complexity and effort of implementing and maintaining them. Every single tool may have dependencies on the external source or system, so the maintenance complexity grows linearly with the number of tools supported. This problem is not sustainable, even more so due to the nature of this project.

The ideal solution would:

  • Be isolated and connected through some interface, so it is not part of the software development life cycle of Vatuta
  • Ideally, the MCP server should be maintained by the owner of the source or by a trusted community/vendor, so changes in the external API are handled closer to the source, and the maintenance effort does not rely on Vatuta
  • As it is external code, it may not be trustworthy, and we should be able to run it in an isolated and secure manner
  • The integration and invocation must be dynamic, based on configuration only, not requiring any change to Vatuta’s code

MCP is a natural solution for this kind of integration problem. Introduced by Anthropic, MCP provides an open standard for LLMs to securely interact with external tools and data sources. MCP servers provide operations and resources as an interoperability layer with external or third-party systems or data sources. MCP standardizes how tools, resources, and prompts are exposed, including their names, descriptions, input schemas, and outputs, so the agent can decide which capabilities may be useful.

An MCP server is an instance that runs independently, providing the implementation and functionality through the MCP interface to a client, an agent. If the functionality relies on a third party, the MCP server also manages the communication with it. The MCP ecosystem is growing quickly, and servers are already available for many common services and data sources. This is a great advantage to avoid the implementation and maintenance effort, but also a great risk, as it may be untrusted code manipulating our data, adding dishonest data to the context, or even operating on these third-party systems on our behalf.

An MCP server provides the client with tools (callable operations), resources (static content), and prompts (prepared prompt templates for LLMs). The client may provide, if desired and enabled, access to roots/files or allow the MCP server to request LLM sampling through the client. In our implementation, Vatuta only supports MCP tools exposed by the server.

2. Dynamic tool integration: How MCP works in Vatuta

MCP servers are set up in the Vatuta configuration. There are no code-specific or platform requirements, except Docker. Vatuta itself identifies the MCP Docker image and creates a container to run it.

After the MCP server instance is started, Vatuta connects to configured MCP servers, queries their tool catalogs (list_tools), and automatically converts JSON Schema metadata into typed Pydantic models for DSPy tools (MCPToolWrapper). That means that the tool arguments, their schema, and return value are dynamically loaded and interpreted in order to make them available to the routing agent that analyses the user’s query. If the MCP server provides new tools or argument logic, they will be updated in the next Vatuta start.

The routing agent that analyses the user’s query is a ReAct agent that iterates in a multi-step strategy through the query, collecting data from the different tools and applying filters to the RAG query (the filter applications are also tools) until all the information needed is collected by the available tools. The agent knows the list of tools, but also the arguments to call them and the expected response, so it can guess which tools are useful and how to call them.

There is the temptation to add as many tools as possible in order to enable more functionality to the agent, but this will cause the agent to manage so many tools that its efficacy in selecting the right tool decreases. Until a mechanism that manages this issue is applied, it is very important to just enable the tools that are needed. Therefore, a Regex-based whitelisting allowed_tools ensures that only explicitly allowed capabilities are exposed to the agent. This reduces the attack surface, although it does not make a tool safe by itself.

The routing agent persona was refined so it seamlessly orchestrates both internal RAG document filters (Jira/Confluence/Slack) and external MCP tools in multi-step trajectories.

The standard MCP transports are stdio and Streamable HTTP. For this implementation, I selected stdio: the standard input stream (stdin) is used for the client to invoke the MCP server, which answers through the standard output stream (stdout), both on the Docker process running the container. The standard error stream (stderr) shows error messages to the client, Vatuta. The asynchronous Docker stdio sessions (stdio_client) were also integrated into the synchronous LangGraph nodes & DSPy ReAct routing (AsyncLoopThread).

In the example configuration, two MCP servers were integrated as an example:

  • mcp/everything, a common MCP sample server and its add tool
  • mcp/wikipedia-mcp, a Wikipedia search and content-collecting MCP server and its search and read tools

3. Under the hood: Security and container sandboxing

MCP servers are third-party code and must be considered untrustworthy. The MCP server may be manipulating sensitive data from our context; it may have credentials and access to sensitive internal services and systems, and also inject data into the context. Therefore, the integration philosophy was based on a zero-trust approach.

The MCP servers are run in an isolated environment based on Docker in order to avoid running in the same environment as the host running Vatuta. The MCP transport protocol selected is stdio to avoid exposing an HTTP endpoint and to keep communication tied to the container process. Network access is blocked separately with Docker’s --network=none by default.

Before running containers, the images are pinned to the image digest. If the image digest is used to identify the image in the configuration, such an identifier is used. If a tag were used, metadata is extracted first, and then the container is pinned to the digest related to this tag metadata. The images are not automatically pulled from the repository unless the pull parameter is enabled.

The container is run in a hardened Docker execution profile:

  • Mounted in a read-only root filesystem (--read-only) with ephemeral data in-memory /tmp (tmpfs).
  • With no access to the network unless it is explicitly enabled per server (--network=none by default, togglable per server).
  • Based on least privilege execution: Unprivileged user (1000:1000), dropping all Linux kernel capabilities (--cap-drop=ALL), and no-new-privileges=true.
  • With strict bind mount validation: Read-only (ro) mounts only, with strict path validation blocking host system directories (//etc/var/run/docker.sock).
  • Secrets from Vatuta can be passed to the Docker environment (like third-party tokens) through environment variable pass-through (env_passthrough). Secrets should only be passed when strictly needed, scoped per server, and with the minimum permissions required.

This does not make third-party code fully safe, but it reduces the attack surface and limits the potential impact of a compromised or malicious MCP server.

The whole security philosophy is described in the project documentation.

4. Centralized Logging Architecture

The whole logging architecture has been unified, centralized, and refactored. In former versions, there was still process information shown with print calls, and it was not possible to set the level of logging per component.

The logging library has been applied to all source components, creating loggers with proper names to identify the source of every event. The use of lazy printf formatting has also been followed in all logging operations.

The centralized logging configuration file can be found at config/logging.yaml, and it contains the logging settings for the whole Vatuta, allowing the routing and filtering of events depending on the different components from a unique point.

The MCP container stderr output is captured and forwarded as logging events in loggers called external.mcp.[mcp server name].

5. Other version 0.3.0 highlights

  • Poetry 2.0 Specification: Migration of pyproject.toml to the official Poetry 2.0 [project] standards.
  • All dependencies have been upgraded to resolve the known vulnerabilities reported by the current dependency scan at the time of the release.

6. What’s Next

I am working on enabling Vatuta as a policy/procedure validator or enforcer. It is quite common that a manager’s work includes validating that the standards, procedures, and policies are followed. Vatuta can collect the needed data, evaluate the policies defined in natural language, and report whether there are violations.

Categorías: Agents, LLM, Seguridad, Vatuta

Vatuta 0.2.0, some improvements

viernes, 3 julio 2026, 0:43 Deja un comentario

Hybrid search

Until now, Vatuta has only supported semantic search based on dense embeddings. Although this mechanism is quite useful for searches using synonyms or for small queries where little context is included, it is quite inaccurate for matching entities, IDs, or exact terms.

To improve the search for exact terms or entities, sparse vector indexing has been added to the search mechanism. BM25 is a probabilistic lexical ranking function that builds on term-frequency and inverse-document-frequency signals while adding term-frequency saturation and document-length normalization. BM25 prioritizes documents that contain the query terms when, at the same time, those terms are not contained in the rest of the documents. It prioritizes exclusive and unique document terms.

It is expected to improve the robustness and recall of chunks and documents when the search is based on Jira ticket IDs, users’ conversations, or specific contextual terms and concepts.

The ingestion and retrieval processes have also been reviewed, as the processing of the text or content is quite different and more elaborate than the one used for embedding vectors:

  1. The tokenizer used in this case is a SimpleTokenizer that splits text by spaces and punctuation marks. Special characters are removed, and the text is converted to lowercase.
  2. Stop words such as the, of, an, and with are removed and not indexed by using the FastEmbed library, which supports around 20 languages, including English and Spanish.
  3. The Snowball Stemmer algorithm is used to perform stemming on the tokens. Stemming maps some morphological variants to a common approximate stem, reducing the vocabulary size and improving lexical matching. By reducing the tokens to their roots, we reduce the size of the indexes and group the tokens by their roots. Inverted, inversion, and inverting all refer to the same stem, invert, for example.
  4. FastEmbed generates one sparse-vector dimension per processed token. For documents, the value is the BM25 term-frequency component, including frequency saturation and length normalization. Qdrant applies the corpus-dependent IDF component at query time.
wBM25(t,D)=f(t,D)(k1+1)f(t,D)+k1(1b+b|D|avgdl)\operatorname{w_{BM25}}(t,D) = \frac{ f(t,D)\,(k1+1) }{ f(t,D) + k1 \left( 1-b+b\frac{|D|}{\operatorname{avgdl}} \right) }

When searching for the documents or chunks most closely related to a query, BM25 calculates the most relevant documents by ranking them by score. The score is calculated in BM25 for every document in the corpus against the query. The score is the sum, for each query term, of its IDF multiplied by a term-frequency component that applies diminishing returns to repeated occurrences and normalizes the frequency according to the document length. The IDF, or inverse document frequency, is the logarithm of a smoothed ratio between the number of indexed units that do not contain the term and those that do. The IDF ratio gives more importance to terms that are rare and belong specifically to some documents than to terms that are common and do not identify particular chunks or documents.

scoreBM25(D,Q)=tQln(1+Nn(t)+0.5n(t)+0.5)f(t,D)(k1+1)f(t,D)+k1(1b+b|D|avgdl)\operatorname{score_{BM25}}(D,Q) = \sum_{t \in Q} \ln\left( 1+ \frac{N-n(t)+0.5}{n(t)+0.5} \right) \cdot \frac{ f(t,D)\,(k1+1) }{ f(t,D) + k1 \left( 1-b+b\frac{|D|}{\operatorname{avgdl}} \right) }

k1 is a factor that controls frequency saturation, so smaller frequencies have a proportionally bigger impact than larger ones. The more the term appears, the better, but it makes sense to consider the first occurrences more than the last ones.

b is the factor that controls document-length normalization, so the frequency of a term is penalized in longer documents compared to shorter ones. Therefore, an occurrence in a shorter document weighs more than one in a longer document. The length-smoothing term reduces the effect of longer documents containing the terms simply because they are longer.

N is the number of documents or chunks indexed by Qdrant with BM25. n(t) is the number of documents in the corpus in which term t appears. f(t,D) is the frequency, or number of occurrences, of term t in document D. |D| is the length of document D. avgdl is the average document length in BM25, but it is not calculated across the corpus in Qdrant; it is a hyperparameter estimated during the design or optimized during the validation of the system.

When retrieving chunks in a search, the new hybrid mechanism collects them using both the sparse-vector algorithm, BM25, and the dense-vector algorithm, multilingual-e5-small. Both algorithms are independent, and their weights and ranks—the relevance order produced by each one—are unrelated.

The combination of both result sets is performed through Reciprocal Rank Fusion, or RRF. It is based only on the rank of the document, not on its score. Therefore, the documents are combined based on the order in which they appeared in each of the algorithms. There is a smoothing mechanism for reducing the effect of the differences between positions based on K, with a common value of 60. The new score of every document is the sum of the inverse ranks smoothed by the K factor, with a contribution of 0 when the document is not present in a ranking. Qdrant supports other fusion mechanisms, but RRF usually requires less score calibration than score-based fusion because it operates on ranks. Nevertheless, its constant and input weights should be evaluated on a representative query set.

RRF(D)=rwr1K+rankr(D)RRF(D)=\sum_r w_r \cdot \frac{1}{K+rank_r(D)}

The K and w factors of the RRF mechanism, the k1 and b factors, and the avgdl hyperparameter from BM25 have not been evaluated in any way and have become part of the technical debt of the project.

Semantic embedding model

The embedding model is the model that translates the content, query, or chunk from the sources from text into a vector of a fixed length. The vector is supposed to represent the meaning of the text, so texts with similar meanings should have short distances between them. These vectors are compared with one another using the cosine similarity function in order to compare the semantic similarity of the whole content, instead of term weights like in BM25.

Initially, Vatuta used sentence-transformers/all-MiniLM-L6-v2 as the embedding model. This model is suitable, but it was switched to intfloat/multilingual-e5-small. The reasons for the switch are the following:

  • all-MiniLM-L6-v2 is mostly trained using English only, while multilingual-e5-small supports more than 100 languages.
  • all-MiniLM-L6-v2 has a maximum length of 256 tokens, while multilingual-e5-small has a maximum length of 512 tokens. The supported chunk size without truncation is twice as large, which allows it to capture a more cohesive meaning, although the optimal chunk size must still be evaluated because larger chunks can mix unrelated information.
  • multilingual-e5-small is specifically trained for semantic search and retrieval. It is designed specifically for multilingual retrieval and is expected to outperform all-MiniLM-L6-v2 in this use case.

There was a bug in the original (version 0.1.0) chunking implementation. I did not consider the maximum size, or number of tokens, supported by the embedding model. Therefore, it was possible for the chunk length to be bigger than the length supported by the model, causing the chunk to be truncated to the model limit. This may cause the loss of the meaning contained in the truncated part. To fix this, the chunking algorithm now considers the length limit configured as a parameter, but also the length constraint imposed by the embedding model. It then uses the minimum value of both when splitting the content.

In addition to switching the embedding model, the whole processing of the content was reviewed:

  • multilingual-e5-small requires some prefixes to be added to the content before the embedding is calculated. As described in the model card, we must add a prefix to the content depending on whether it is part of the RAG content or the query itself. This is required because the model was trained using these prefixes, and RAG performance decreases significantly if they are not used. The prefixes represent the asymmetry of the content depending on whether it is part of the query or the response. The query content must use the query: prefix, and the RAG content must use the passage: prefix.
  • Although cosine similarity is used and it already performs normalization of the embedding vectors, a setting was added to force vector normalization in all cases. Normalization allows the cosine similarity calculation to be reduced to a scalar product. much faster.
  • Embedding models do not require stemming, lemmatization, or the removal of stop words. They require the whole text and its word order to preserve the semantics of the sentence. This is contrary to sparse models, whose performance improves when using the former mechanisms.
  • The tokenizer used by multilingual-e5-small is SentencePiece, a tokenizer that performs quite well when switching languages or using several languages. Its tokenization uses spaces as part of the tokens, supporting in this way languages that do not use spaces to split sentences into words. multilingual-e5-small is based on the XLM-RoBERTa language model, which uses SentencePiece, but it is specialized for semantic search.

Instrumentation

After the bug explained in the previous section, instrumentation was also improved to obtain more metrics that allow the ingestion and chunking of sources to be monitored:

  • ingest_documents_total (Counter): Tracks the total number of high-level document units ingested. A document unit represents a single entity from a source, such as a full Confluence page, a Slack channel thread, or a Jira issue.
  • ingest_chunks_total (Counter): Tracks the total number of individual text chunks generated from the ingested documents. It helps measure chunk density.
  • ingest_document_size_chars (Histogram): Measures the distribution of document sizes in characters before chunking. It is useful for understanding the size of incoming raw documents.
  • ingest_chunk_size_chars (Histogram): Measures the distribution of final chunk sizes in characters. It helps confirm whether the chunk-size strategies are working as expected.
  • ingest_chunks_per_document (Histogram): Tracks how many chunks are generated from a single document. It is useful for identifying documents that generate too many or too few chunks.
  • ingest_chunk_token_budget_ratio (Histogram): The ratio of the chunk size, in characters, to the maximum character capacity of the embedding model, which defaults to 1024. A ratio (> 1.0) indicates that the chunk is too large and will be silently truncated by the embedding model.
  • ingest_embedding_latency_seconds (Histogram): Tracks the time, in seconds, spent generating embeddings, for example during semantic splitting strategies.
  • ingest_chunk_split_reason_total (Counter): Tracks the trigger that caused a chunk to split. The reasons differ depending on the source type. The reasons can be:
    • time: The maximum time interval between items was reached in Slack.
    • size_chars: Aggregated chunk character limit reached.
    • size_count: The maximum item, message, or comment count limit was reached.
    • semantic: The cosine similarity between consecutive items dropped below the threshold.

Vulnerabilities management

The version also includes several dependency upgrades to fix the vulnerabilities found after version 0.1.0 was released. However, the diskcache dependency still has a vulnerability because it uses pickle to serialize the cache content to disk. Pickle is considered a threat because it may serialize or deserialize executable content. An alternative is still needed for the persistence of the entity cache.

Categorías: Arquitectura, Desarrollo, LLM, Vatuta

Vatuta, a RAG for managers

domingo, 26 abril 2026, 22:06 Deja un comentario

A problem

After so many years working as a manager, I had the feeling that one of the most stressful tasks to accomplish is to be aware of all the information and being able to act and respond with the right, proper, and updated data:

  • When you are focused on one topic, it is very difficult to stay updated on the other ones.
  • The information arrives and flows through so many different channels that it is very difficult to follow all of them. It may also be incoherent across them.
  • The time needed to read all sources and be able to interpret all of them would consume all your work time, forcing you to do actual work in overtime, when the sources’ activity decreases.
  • The stress of feeling that you would not be able to respond or act when something happens because you are not updated enough.
  • The stress you, as a manager, cause to the team when you request updates and reports at an unexpected time or with very high frequency.

An idea

AI and NLP, especially LLMs, helped some time ago to mitigate this manager stress in meetings by transcribing and summarizing, features now common in many call applications. We can apply the same technologies to reduce stress and help manage information flows for managers.

We can use a common RAG architecture to help managers have higher confidence that relevant information can be retrieved when needed. The sources are collected and processed into a vector database where the information is available for queries. When performing a query, the RAG system collects the related documents for the query and uses them as part of the context for the LLM to elaborate a response.

Typical sources of information are:

  • Documentation like content in Confluence, Notion
  • Tickets from JIRA or similar systems
  • Conversations from chat systems like Slack or Teams
  • Issues and pull requests from code repositories
  • The code itself, its comments, and internal documentation
  • Emails
  • Calendar events, meeting notes, and transcripts
  • Web content and search API responses

Although the most common use case is the typical question-answering use case based on RAG systems, it may also be used to summarize topics, write time sequences of events, or search for specific references and documents in sources. Source citations can help mitigate hallucinations and make answers auditable, although they do not fully guarantee correctness.

Although the current first implementation of the system is completely reactive, as it responds when invoked with a question, it can be improved to act proactively when an event is triggered or periodically.

The implementation

The solution is implemented in Python. The RAG behavior is based on LangChain and LangGraph, as they are great frameworks for managing the flow of the solution. But the invocation of the prompts is based on DSPy, as it allows us to manage those prompts as atomic operations, which is very useful to optimize and reuse them. The vector DB is Qdrant, as it is a great solution, balancing performance and functionality. You can find the whole stack of libraries and components used here.

An ecosystem of common tools and libraries for Python is used, like Poetry for dependency management, pre-commit scripts for compliance assurance, GitHub Actions for CI tasks, Ruff for code formatting, mypy for static checking, pytest for unit testing, Typer and Rich for the command console, Prometheus for observability, just for developer UX comfort, Hugging Face libraries for diverse NLP and transformers tasks, Bandit, Semgrep, and pip-audit for security management…

The UX is based on a command-line client that supports both the commands for ingestion of sources and asking questions. It can be easily extended to other interfaces, like a chatbot one. The whole parameterization is based on config files, so the command arguments are limited to what is strictly necessary. It is intended to make it easy to check and validate the project as the PoC it is.

Source ingestion

The first step before any question or command can be requested is to ingest data from sources into the vector database. The solution collects raw data — messages, documents, tickets… — from the source and stores it locally. This data is stored in a cache, as the raw data may be processed several times. If the processing flow of data changes, evolves, or is refactored in some way, the data does not need to be collected again. The data collection is a batch process. It is collected from the source using some filtering criteria — channels, projects, spaces… — and restricted to a temporal range. This range starts from the last time data was collected and goes to the current moment in time, in an incremental manner.

The data, depending on its nature, must be serialized, transformed, and split into chunks and documents. The data is processed sequentially to be split and joined into chunks. A chunk is the minimal document entity in the vector database. When creating the chunks, some attributes are extracted from the content and added to the chunk metadata. This metadata is used to filter and restrict the search in the vector database to constrain it to a narrower scope than only using the embedding. The chunk and its embedding are stored in the Qdrant vector database with their attributes.

These attributes can be the time span of the chunk, so we can limit the search to a specific period, or the kind of source, so we can limit the search to this source type, for example. Additionally, entities like users/actors are also identified and added as attributes. Those entities are matched across different sources by using common linkable data, so their identity is preserved across different sources.

Although the hierarchy of chunks into documents can be quite flexible, allowing more complex structures, the current implementation just groups the chunks into a single document, and only chunk-based search is used.

Every source type requires a different strategy depending on its nature and content structure:

Confluence articles are split by sections into chunks. When the content of sections is too big, it is split into smaller chunks with a maximum size, trying to avoid dividing paragraphs or code sections.

JIRA tickets are split into chunks based on the different sections and content a ticket can have. The whole body, description, and main attributes are the first chunk. The relationship with other tickets is a second one. The history of the ticket is a third one, but it is split into several chunks of 20 items each. Finally, the comments of the ticket become chunks, keeping a maximum size in characters and comments per chunk, but the chunk is also split if the semantic similarity of the current comment compared to the previous one is below some threshold, to detect a change of topic in comment threads.

The Slack source divides the different channels and the threads inside them into sequences processed independently. Every conversation is split into chunks in several ways at the same time: messages within a time span are kept in a single chunk — several hours — but they can be split into several chunks when a character or message limit is reached. Finally, to keep the same topic in every chunk, there is a semantic similarity check, so messages are kept in different chunks when their similarity is below some threshold.

Questioning

When asking a question, a dynamic routing strategy is put in place. The answer is processed in two stages managed with LangGraph.

The first stage takes care of collecting the right data for answering the question. In order to do that, this stage behaves following the ReAct — Reason Act — pattern with several tools available. These tools limit, restrict, or complement the query so the embedding is compared against an already filtered set. The agent interprets the user’s query and chooses the proper tools to set this filtering.

Examples of tools or filtering criteria are the time range of the source, i.e., “from the last month”, or the type of source, i.e., “from JIRA tickets”. If the agent, following system prompt instructions, detects a reference to some time span or to a source type in the query, it will call the related tools to create filtering criteria over the chunks metadata in the vector database. Then, a similarity semantic search, based on embeddings, is performed on the Qdrant vector database, but against the subset of filtered chunks. Therefore, the subset of chunks compared with the embedding of the question is smartly restricted by the query before comparison. From all the compared documents, the most similar k are collected, k being a command-line parameter.

Other tools allow the system not to filter, but to add and collect documents or data directly into the context. For example, if a JIRA ticket reference is detected in the query, the content of the ticket is retrieved and added to the context documents for the next stage.

This mechanism allows the system to point to the right documents for answering the query, improving the correctness and accuracy of responses. This agentic routing provides flexibility, and it is more useful when the query requires interpretation or multi-step retrieval. But deterministic extraction may be preferred for obvious constraints such as dates, source types, ticket IDs, and user mentions.

The second stage takes care of answering the query by using an LLM prompt including the documents collected in the previous stage, with the query and a proper system prompt.

Flags in the command line allow showing the retrieved documents, the applied filtering criteria, and an execution trace of the routing stage: selected tools, tool inputs, intermediate results, and final retrieval decisions.

All prompts are based on the DSPy library, which helps to manage them programmatically, as an API call. DSPy can be used to optimize the prompting strategy by defining the program structure, representative examples, and evaluation metrics. This makes prompt optimization more systematic than manually editing prompt strings.

The challenges

Sources nature

Sources have a different nature, completely different from each other. The strategy used to collect the data and to process it into chunks is quite different. Every integration pattern based on APIs, streams, or exported documents is an integration use case that needs independent work. There is no standard or service, as far as I know, that aggregates different sources into a single common pattern ready for ingestion.

Confluence and JIRA sources are comparatively easier to model because they expose clearer document-like entities, as the document concept is clearly defined and split into sections or parts that can be ingested as chunks.

But Slack sources do not have such a concept of a document. Even when channels and threads can be considered documents, they extend over time, and the topics are so different and changing that it is quite difficult to isolate them into chunks or documents.

The strategy followed is to group messages in the same channel or threads until some inactivity is found. After 4 hours of inactivity, we consider the topic is no longer related between the last and the new message. This is a heuristic for simplification, and it may be improved. Even when the inactivity threshold is not reached, when the chunk size in characters or messages reaches a limit, the chunk is split to avoid chunks that are too big. Finally, even when the size or time limit is not reached, every new message embedding is compared using cosine similarity with the last one. If the similarity is below a threshold limit, we can assume the topics in both messages are very different, and the chunk should be split. For this purpose, the embedding algorithm used is multilingual-e5-small, which is extremely light and fast to boost the ingestion process.

Semantics

As the chunk is located by its embedding, in some way, this embedding stands for its meaning. If we choose very small chunks, their embeddings don’t capture the real essence and meaning of the context of the sources, and they become useless. If we choose very big ones, their embeddings cover too broad and superficial meanings, which are also useless because they would be much less specific, and the right chunks for the query would not be selected.

The solution is part of the previous section. The right chunking of the source so its contents capture the right topic or meaning is essential, but so is the embedding algorithm that converts the chunk to a vector. The algorithm multilingual-e5-small is used because of its performance and resource usage. Embedding algorithms have a maximum token length. The solution chunks the content of the source, keeping in mind this limit.

Batching vs streaming

Collecting the source data in batches is easier, and its processing is even easier as you have the whole content to perform the chunking. But, in order to be more proactive instead of reactive, data should be ingested in real time. This implies a lot of complexities not considered in batching, like chunks that are not completely closed or that are rewritten once a new message belonging to them comes to the system. Additionally, batch collection through APIs is easier, and sometimes the only choice, compared to pulling streams or events.ts.

Embeddings are not enough

Embeddings are essential for RAG systems, as they provide the semantic classification for collecting the documents. But their results may be difficult to tune, and adding other strategies helps to improve the results significantly.

The first strategy is to limit the universe of chunks to those that we can be more sure are related to the query. The attributes, metadata, or identities extracted during ingestion are used to filter the chunks to compare for embedding similarity. The right selection of the filtering will constrain the set of chunks to those that belong to the subject or matter of the query.

The problem is that such a filtering criterion cannot be guessed in advance; it depends completely on the query. The filtering has to be guessed from the query. A ReAct agent calculates it from a query in the first stage and provides the subset of documents to be included in the context for RAG. The agent may also collect documents by other means, like the whole JIRA document or by pulling data from APIs.

This is the way it is implemented right now, but it can still be improved. We went for the filtering approach to improve the embedding-comparison-only strategy. But filtering of chunks may also be inexact or faulty. We are considering that all attribute or identity extraction works precisely, and no document is discarded by mistake. That is not the case. If the right document is filtered by mistake, the answer will never be accurate and complete.

The idea is not to filter out chunks, but to re-rank them by using the criteria. Those chunks matching the criteria will have a better score, so they will be reordered to the top. If a chunk was previously discarded because it did not match a small part of the filter, now it will still be scored. Then a top-k truncation is performed, but we are better ensuring that the chunks are relevant to the query.

Model selection

Depending on the task, the right LLM model may be different. Even when a model may be effective for a task, it may not be efficient due to its cost or resource usage.

For the first stage of the querying, the one in charge of identifying the filtering criteria of chunks and retrieving the related documents, the model must support tools, but it may not need reasoning or great performance. Additionally, because the ReAct pattern is a model that is called many times per query with short prompts, a big context size is not needed.

The second stage does not require tooling. Its main responsibility is to synthesize an answer from the retrieved context. However, depending on the question, this stage may still require reasoning, as it may significantly improve complex responses. In this case, as the documents are included in the prompt, models with long context support and good performance are needed.

The solution supports configuring the selection of the model from the provider’s catalog. DSPy and LangChain support many providers, so model switching should be quite easy and direct.

To-dos and improvements

The current status of the project may be considered a proof of concept. Some ideas to improve in the future and continue exploring are the following:

  • Embeddings are calculated with the algorithm all-MiniLM-L6-v2. Although it is quite efficient and has low resource consumption, it is constrained to 256 tokens. The current solution doesn’t control the chunk size according to this limit, and that means chunks are truncated for embedding calculation. This must be fixed with a proper algorithm or chunk size control. (Embedding replaced by multilingual-e5-small. Mechanism for truncation detection added)
  • Sources are ingested in batches scheduled periodically and on demand. This simplifies the ingestion and processing of sources, but prevents any proactive or real-time action. Pushing of sources or streaming may be a very interesting feature.
  • Responses right now do not include citations, becoming more vulnerable to hallucinations, or at least less auditable. Enabling direct linking to sources and forcing it in the prompt would be very useful and helpful for validation, too.
  • Chunks are filtered through metadata and attributes before embedding comparison. Wrong or missing attribute extraction during ingestion will filter potentially relevant documents. By reranking instead of filtering those chunks, although ordered at a lower level, they may still be included in context.
  • The current Qdrant search is based on filtering by metadata and embedding comparison. Qdrant can support hybrid retrieval by combining dense semantic vectors with sparse lexical representations and metadata filtering. This would help with exact terms, ticket IDs, names, acronyms, and other cases where dense embeddings alone are weak.
  • The current RAG solution is quite useful for specific, detailed, and accurate questions. When answering summaries, stats, or general topics, this solution doesn’t work well or requires a very big context, with too many chunks in it. A solution like GraphRAG to support general topics, relations between elements, summaries, or stats would be a very interesting exercise.
  • Quite related to the previous point, an unsupervised topic classifier would be useful to classify questions and chunks, and filter or rerank by their topic.
  • No validation was done at all 😓, and unit testing requires a lot of improvement.
  • Prompting is supported by the DSPy library. Prompt optimization can be performed automatically with DSPy by adding metrics and samples for optimization.
  • The current solution is totally reactive; it requires the user to ask a question. It would be quite interesting to respond to triggers or be scheduled in some way for proactive behaviour. This is quite related to the sources being ingested in real time.
  • There is no security analysis in the solution, beyond the dependency scan with pip-audit or the SAST analysis with Bandit or Semgrep. A more detailed security analysis would be needed based on the OWASP Top 10 for LLM / GenAI, for instance.
  • Several LLM models, Anthropic and Gemini ones, were used, but other LLMs may be tested when some validation is ready.

Conclusions

These are the main insights I personally get from this experience:

  • Embeddings are not enough. Metadata, identity extraction, lexical search, and reranking can significantly improve retrieval, but hard filtering should be applied carefully because it may remove relevant evidence.
  • Dynamic routing is a great pattern because of its flexibility to filter, rerank, and select the right documents for the context.
  • Very useful for specific questions about concrete topics or events, but weak for global reporting, aggregate metrics, and broad summaries unless combined with precomputed summaries, structured analytics, topic models, or GraphRAG-like approaches.
  • Source integrations are complex and highly source-dependent. Each source has its own structure, API limitations, semantics, and ingestion challenges.
  • Different operations require different models and embeddings. Combining several models with different strengths, costs, and drawbacks is important to build a more effective and efficient system.
Categorías: Agents, Arquitectura, LLM, Vatuta Etiquetas: