InvestigatorAI is an investigation assistant. It answers questions that need both structured data and documents, like this one:
“Who really controls Costruzioni Ferretti Srl, and are there conflicts of interest with ex-mayor Luigi Conti in the awarding of public contracts by Comune di Brescia in the period 2022–2023?”
To answer it, the system must follow the company ownership chain, build a profile of each person across roles and years, read public procurement data and find documents that support the findings. Each part uses a different data source and a different kind of reasoning. The system is multi-agent: each agent handles one part, with the data source and the tools for that part.
System architecture
Bolt :7687 · Browser :7474
Spring Data Neo4j · @Query projections
REST :6333 · gRPC :6334
MLX embed server · Nomic ModernBERT
You can replace everything below the agent layer without changing the agents. The agents do not call Neo4j or Qdrant directly. They call named tools, and the tools are the only interface between the agents and the storage.
For example, you can replace Qdrant with Weaviate, add a Redis cache or change the embedding model, and the prompts and the routing stay the same.
Why multi-agent
Each agent is an LLM with a fixed system prompt and a small set of tools, exposed as a Java interface. The tools define what the agent can see and call.
The supervisor works the same way. It is an agent, and its tools are the specialist agents.
One ownership question needs many different kinds of work. The system must split an unclear question into parts, send each part to the right data source, traverse graph relationships, rank evidence from documents and write a structured answer with a confidence level for each finding. Each step needs different context and fails in a different way.
With one agent per task, each with its own prompt and tools, a failure stays in one agent and is easier to find. An agent can only read data through its own tools. The list of tools sets this limit, and the prompt does not have to. The model can still write a wrong claim, and the source verification step described below checks the claims against the documents.
From query to agents
The design started from the reference query. Each part of the query needs a different kind of data access. The ownership structure needs graph traversal. The conflict of interest needs relationships across roles and contracts. The person profiles need both graph data and mentions in documents. These parts decided how to split the agents and which tools each agent gets.
The query maps to three specialist agents and two agents that work on all findings. CorporateAgent and FinancialFlowAgent both read from Neo4j, but from different parts of the graph: CorporateAgent reads the ownership structure, and FinancialFlowAgent reads the contract awards and amounts. PersonProfileAgent uses both stores and combines the graph relationships of a person with the documents that mention them. DocumentAgent and SourceVerificationAgent do not handle one part of the query. They work on all the intermediate findings: they search the document store for supporting evidence and check each claim before it gets a confidence level.
The supervisor receives the original query and the focusEntities from the API request. There is no preprocessing step for entity resolution or date extraction. The supervisor LLM reads the query, chooses the specialist agents and passes structured arguments to their tools, such as company names, person names and date ranges. The Cypher queries match the entities by exact name.
At runtime, SupervisorAgent calls the specialists as tools and chooses the next step from the intermediate results. If CorporateAgent finds an offshore owner, the supervisor passes it to the next calls together with the entity of the original query. The supervisor decides the order of the calls. Each specialist can only use its own tools.
The agent layer in detail
InvestigationReport: typed Java record with findings, confidence levels, entity map, follow-up recommendationsEach agent is a Java interface, created with AiServices.builder() in AgentConfiguration, with one facade tool per agent. The system prompt of each agent is in /resources/prompts/<AgentName>-system.txt, so you can change it without recompiling. All agents use one ChatModel bean that points to the MLX server. The model, the temperature and the max tokens come from the global mlx.* properties, and there is no configuration per agent. The facade tools and the two data tools are Spring @Component beans. The agents share no state except these tool beans.
The tool layer
The agents read live data through tools. In LangChain4j a tool is a Java method annotated with @Tool, which the model can call as a function. The agent chooses the tool, passes structured arguments and receives a result. Without tools the model knows only its training data and what you put into the context. Neither contains the current ownership structures, the contract awards or the relationships in a given jurisdiction.
@Component
public class GraphTraversalTool {
private final GraphService graph;
public GraphTraversalTool(GraphService graph) {
this.graph = graph;
}
@Tool("Find the Ultimate Beneficial Owner (UBO) of a company by traversing " +
"the ownership chain up to 5 hops. Returns the natural persons " +
"who ultimately control the company.")
public String findUBO(@P("Full legal name of the company") String companyName) {
var persons = graph.findUBO(companyName);
if (persons.isEmpty()) return "No UBO found for " + companyName;
return "ULTIMATE BENEFICIAL OWNERS of " + companyName + ":\n" +
persons.stream().map(this::formatPerson).collect(Collectors.joining("\n"));
}
@Tool("Detect potential conflicts of interest: find persons who held a " +
"public role at a body that issued contracts, and also owned or " +
"directed companies that won those contracts.")
public String detectConflictOfInterest(@P("Full name of the person") String personName,
@P("Start date ISO-8601, or null") String dateFrom,
@P("End date ISO-8601, or null") String dateTo) {
var conflicts = graph.detectConflictsForPerson(personName);
if (conflicts.isEmpty()) return "No conflicts of interest detected for " + personName;
return "CONFLICTS OF INTEREST DETECTED:\n" +
conflicts.stream().map(this::formatConflict).collect(Collectors.joining("\n"));
}
}
GraphService wraps the Neo4j repository. The specialist agents do not call these methods directly. They call a facade tool, such as CorporateAgentTool or PersonProfileAgentTool, that calls several methods of GraphTraversalTool and VectorSearchTool and returns one string to the model.
The tool methods return String and not records or JSON, because the model works better with structured text than with nested objects. A return value like this one:
OWNERSHIP CHAIN for Costruzioni Ferretti Srl:
- Marco Ferretti → OWNS 77% (indirect, IT) via LuxHold SA
└─ LuxHold SA → CONTROLS 100% (LU) [TAX HAVEN]
- Mario Conti → OWNS 15% of LuxHold SA (IT) [FAMILY TIE: brother of Luigi Conti]
- Esposito Offshore Ltd → OWNS 8% (LU) [INACTIVE since 2021]
lets the model use markers like [TAX HAVEN] or [INACTIVE] without extra instructions. The model chooses tools only from the text of the @Tool annotation. A vague description makes the agent call the wrong tool, and a wrong description means the tool is never called.
Data layer: graph and vector
Neo4j: structured relationships
The main question of the system is whether a person with a public office had hidden financial ties to a company that won contracts while the person was in office. The query follows several relationships: from the person to the public role, from the role to the contract, from the contract to the company, and from the company back to the person through ownership or a director role. In a relational database this needs recursive CTEs and several joins, and each extra relationship makes the query harder to read and check. In Neo4j it is one Cypher match that follows the domain model:
MATCH (p:Person)-[:HELD_PUBLIC_ROLE]->(pb:PublicBody)-[:ISSUED]->(ct:Contract)
-[:AWARDED_TO]->(co:Company)<-[:OWNS|IS_DIRECTOR_OF]-(p)
WHERE ct.awardedAt >= date($from) AND ct.awardedAt <= date($to)
RETURN p, pb, ct, co
The graph has nine node types and about twenty relationship types:
The project uses Spring Data Neo4j (SDN 8). There is one Neo4jRepository. Every read is a @Query with Cypher, and SDN maps the result to a projection record by the aliases in the RETURN clause, so the tools get typed results. Writes are explicit MERGE statements and not entity save() calls, because the seed must merge data and never needs to load full nodes. The first version used the Neo4j Java driver directly. SDN with hand-written @Query methods needs less boilerplate code, and every query is still visible as Cypher.
Qdrant: semantic meaning
Neo4j stores the data that has a clear structure: persons, companies, contracts, public roles and shareholdings, each with a node type and explicit relationships. The other material, such as court records, news articles, ANAC filings, balance sheets and leaked files, does not fit a schema. Different sources describe the same fact in different formats and words, so an investigator searches by meaning. These documents are stored in Qdrant as vectors and searched by semantic similarity.
The documents are split into chunks of 512 tokens with an overlap of 64 tokens. A Nomic ModernBERT model, served locally by the MLX embedding server, creates the embeddings. Each chunk has metadata that links it to the graph:
{
"source": "sole24ore-2022-05-12",
"source_type": "NEWS_ARTICLE",
"entity_ids": ["person:luigi-conti", "company:costruzioni-ferretti"],
"reliability": "HIGH",
"ingested_at": "2026-04-01T09:12:00Z"
}
entity_ids links the two stores. DocumentAgent reads chunks from Qdrant and includes these IDs in its output. The supervisor can then match a document with the persons and companies that the graph agents already returned, and use both as evidence for the same case.
Observability
In a multi-agent system it is hard to see how the system produced an answer. A query like “Who are the ultimate beneficial owners of Costruzioni Ferretti Srl?” becomes a tree of calls that the supervisor builds at runtime. The supervisor decides which specialist to call, with which input, in which order, and whether to call another one after each result. Two runs of the same query can take different paths. When a finding is wrong or has low confidence, you need to know which step in the tree produced it. Standard APM tools show HTTP calls and latency, and they do not show this.
For each query the trace must contain:
- how the supervisor split the query,
- the ordered list of agents it called,
- the input sent to each agent, which is the user query rewritten for the domain of that agent,
- the tools each agent called, with arguments and return values,
- the token counts and latency of each span,
- the prompt version in use.
Langfuse records all of this. Agent calls become traces, tool calls become spans, and the LangChain4j integration writes them through LangfuseObservabilityListener. The screenshot shows the calls of the supervisor for the Ferretti query. It called CorporateAgent with "Investigate corporate ownership of: Costruzioni Ferretti Srl", then DocumentAgent with "Find documentary evidence about: Costruzioni Ferretti Srl ultimate beneficial owners ownership". CorporateAgent had returned Marco Ferretti as the UBO, so the supervisor then called PersonProfileAgent on "Marco Ferretti". Each panel opens to show the full input, the structured output, the tool calls and the token cost.
When the supervisor returns a finding with low confidence, you can follow the trace backwards: which agent wrote the claim, which tool call returned the data, and what the graph returned. For this, the instrumentation must record prompts, tool calls and model outputs, and HTTP latency is not enough.
CorporateAgent-system.txt each request used.
A complete request walkthrough
This is the Ferretti query from the start of the article, traced from the request to the response. The supervisor decides the order at runtime. It reads the query and the focus entities, chooses the first specialist, then chooses each next step from the results. The order below is the one it chose for this query.
POST /api/v1/investigate reaches the Spring Boot API. InvestigationResource opens a Langfuse session and builds the supervisor prompt from the query and the focus entities in the request body (Costruzioni Ferretti Srl, Marco Ferretti, Luigi Conti). Then it calls supervisor.investigate(...). The model receives the original query without preprocessing.
findOwnershipChain, findUBO (Cypher, up to 5 hops) and findContractsWonByCompany. Neo4j returns the path Marco Ferretti → OWNS 77% → LuxHold SA → CONTROLS 100% → Costruzioni Ferretti Srl. The tool formats it as structured text, and the agent writes a finding.
HELD_PUBLIC_ROLE) of the Comune di Brescia while his brother Mario (FAMILY_RELATION: SIBLING) owned 15% of LuxHold SA. LuxHold SA controls the company that won the contracts.
findContractsWonByCompany on Neo4j. The company won two public contracts from the Comune di Brescia in 2022 and 2023. All its declared revenue for the period came from this one public body.
entity_ids that point to the persons and companies already found in the graph, so the documents and the graph data describe the same entities.
InvestigationReport with a summary, the ordered findings, an entity map, the recommended next steps and a disclaimer. The API returns it as JSON.
localhost:3000. It shows the supervisor span with the specialist spans under it, each facade tool call, the token count of each model call, the latency of each span and the prompt versions. For any doubtful finding, you can open the tool result that produced it.
This query takes 30 to 70 seconds with Qwen3.6-35B-A3B-4bit on Apple MLX on Apple Silicon. The time depends on how many specialist agents the supervisor calls.
Running it
The project is at github.com/valeriomc/investigator-ai. The whole stack runs on one Apple Silicon machine. The two models and the Kubernetes cluster must fit in unified memory at the same time, so I recommend at least 48 GB. With less memory the Mac starts swapping, and inference time becomes unpredictable.
Before any cluster command, start two MLX servers on the Mac with OpenAI-compatible endpoints:
- chat model on
:8081servingmlx-community/Qwen3.6-35B-A3B-4bit - embedding model on
:8082servingmlx-community/nomicai-modernbert-embed-base-bf16
Together the two models use about 22 GB of RAM. The Minikube profile adds 6 GB and 4 CPUs for Neo4j, Qdrant, Postgres, Langfuse and the two Spring services. The project README explains how to install MLX and download the models.
When the two endpoints respond, start the cluster:
make up
make seed
make investigate
make up first checks that both MLX endpoints respond. If one is missing, it stops before it creates anything in Kubernetes, so there is nothing to clean up. Then it creates the Minikube profile, builds the images inside Minikube, deploys the infrastructure and the services and starts the port-forwards. make seed loads the Ferretti scenario (14 nodes, 19 relationships) into Neo4j and stores the embeddings of its documents in Qdrant. make investigate sends the reference query and prints the InvestigationReport. The Langfuse trace is at localhost:3000.
What is missing
Document ingestion is manual. make seed loads test data into Qdrant and Neo4j. There is no pipeline that loads new filings, extracts the entities and keeps the two stores consistent. Every new investigation needs data prepared by hand.
There is no entity resolution. The tools use exact string matches in Cypher. A different spelling, a missing accent or an old company name returns no result, even when the entity is in the graph. The fix is fuzzy matching, for example trigram similarity combined with Jaro-Winkler distance, tuned to avoid false matches. An uncertain match must go back to the supervisor instead of being dropped.
There is no automatic evaluation of the findings. SourceVerificationAgent sets the confidence levels from the evidence it finds, but no test set defines the expected findings and confidence levels for a given query. Today I check prompt changes by reading traces. This works for a few investigation types and will not work for many.
Before production, the system needs the ingestion pipeline, the entity resolution and an evaluation test set. The split of the query, the tools and the tracing are in place.