
Why Choosing Pharma Trial Sites Slows Down When Records, Protocols, and Site History Live in Separate Systems
Choosing sites for a new oncology trial comes down to one question: which sites will actually enroll patients? The evidence needed to answer it sits in four places at once, trial registries, investigator databases, enrollment records, and protocol PDFs, so a feasibility team ends up querying each system by hand and reconciling the results in a spreadsheet.
That manual reconciliation is expensive. Roughly 40 to 50 percent of chosen sites underperform or never enroll a single patient, and each activation costs around $30,000. PSI-CRO AG/LLC built its SYNETIC platform to close that gap, folding site history, investigator data, and protocol text into one contextual layer and taking site identification from six weeks down to minutes across more than 100,000 projects (case study).
This tutorial builds a smaller version of that workflow on the Arango Contextual Data Platform. The query at its center weighs several site-selection variables together instead of sorting on one: physical site and geography, disease prevalence in the surrounding population, demographics such as age, the protocol, and the dosage administered. It returns a ranked list with the reasoning attached, so a reader can see which variable pushed each site up or down.
A Contextual, Multimodel Model for Trial Records, Protocol Text, and Site Relationships
Four signals decide a site: its trial history, how closely past protocols match the new one, how well it has performed, and how many eligible patients live nearby. Each arrives in a different shape:
- Structured trial and site records become graph nodes and edges.
- Unstructured protocol and eligibility text becomes vectors in a vector index.
- Numeric performance sits as fields on the site nodes, read by a plain lookup.
- External population data, keyed by county rather than by trial, joins onto those same nodes.
Keeping those four shapes in four systems is what slows feasibility work down. Arango holds them together and queries across them in one pass.

Graph databases are well suited to relationship-driven analysis: each study links to the sites that ran it and the investigators who led it, and the recommender follows those relationships in a single query. That forms the foundation, but relationships alone aren’t enough. A graph can show how studies connect, not whether a new protocol is clinically similar to past ones. Arango handles that with vector search over embedded protocol text.
So the recommender brings three data types together in one query: document and key-value fields supply concrete metrics like site success and patient age, graph traversal finds sites with relevant trial experience, and vector search finds the protocols closest in meaning. A single AQL query combines them into one ranked recommendation, with no separate services to stitch together.
AutoGraph adds a fourth layer, analyzing the protocol documents to build a context graph from their contents. The next section explains how that differs from a traditional knowledge graph.
Provisioning the Arango Contextual Data Platform and Picking Your Clinical-Trial Data
Prerequisites
Three things must be in place before any code runs:
- Access to the platform with the Agentic AI Suite enabled (AutoGraph and Ada are enterprise-tier, requested through the demo page)
- Python 3.11+
- An OpenAI API key
Install the dependencies from requirements.txt, then copy .env.example to .env and fill in your values:
# Arango Contextual Data Platform
ARANGO_HOST=https://your-deployment.arango.ai
ARANGO_DB=your_database_name
ARANGO_USER=your_username
ARANGO_PASSWORD=your_password
# Provider for LLM and embeddings
OPENAI_API_KEY=your_openai_key
# AutoGraph Retriever (dosage variable)
# Find the service ID in the AutoGraph interface under your deployed Retriever service.
RETRIEVER_HOST=https://your-deployment.arango.ai
RETRIEVER_SERVICE_ID=arangodb-graphrag-retriever-xxxxxConfirm the connection before any data lands:
from arango import ArangoClient
import os
client = ArangoClient(hosts=os.environ["ARANGO_HOST"])
db = client.db(
os.environ["ARANGO_DB"],
username=os.environ["ARANGO_USER"],
password=os.environ["ARANGO_PASSWORD"],
)Choosing Your Dataset
The recommender needs two things from a data source: structured records to build the study, site, and investigator nodes, and high-quality free-text fields for semantic search. Any source that provides both can support the pipeline, so the quality and relevance of the text matter more than the size of the dataset.
This build uses AACT by default, the public database behind ClinicalTrials.gov and maintained by the Clinical Trials Transformation Initiative. It is a strong fit because both structured and text data are available in a single download. The tables provide the graph structure, while fields such as protocol summaries and eligibility criteria provide the text used for embeddings. The build reads the required tables from aact_sample/.
Because AACT covers the full registry, the pipeline uses a focused therapeutic-area slice to keep the build practical on a laptop. The default slice contains breast cancer trials with ACTIVE_NOT_RECRUITING or RECRUITING status, covering roughly 1,688 studies and 23,000 facilities. This filter is configurable through ACTIVE_STATUSES in src/ingest.py, and the ClinicalTrials.gov API v2 can return the same slice without requiring a local AACT copy.
Note: Use your own data
AACT is the default dataset, not a dependency. The loader can be pointed at your own trial data, whether from a registry export or an internal study database, and the same pipeline can run against it.
The Synthetic Performance Layer
AACT records which sites participated in a trial, but not how well they performed. To fill that gap, the pipeline generates performance metrics for each site, including enrollment rate, activation time, success score, and trial count, using random.seed(42) for reproducibility. In a production system, these values would come from a CRM or clinical trial management system. This is one of two synthetic data layers in the pipeline, and because it directly affects ranking, it is highlighted here and revisited in the limitations section.
Modeling and Merging the Data Shapes with Arango AutoGraph
src/ingest.py builds the database in three steps; the structured graph, the embeddings, and the performance layer, defining the schema first so a rerun creates no duplicates.
Defining the Schema
The schema holds four document collections, studies, facilities, investigators, and conditions, plus one edge collection joining studies to sites and sites to investigators.
graph = db.create_graph("site_topology")
graph.create_edge_definition(
edge_collection="site_relationships",
from_vertex_collections=["facilities", "investigators", "studies"],
to_vertex_collections=["facilities", "investigators", "studies", "conditions"],
)A named graph, site_topology, sits over the edges and makes them traversable with AQL.

The four document collections hold the nodes and the single edge collection holds every relationship, which keeps the graph definition small enough to reason about at a glance.
Embedding Configuration
The pipeline uses the same embedding model, text-embedding-3-small (1,536-dimensional cosine vectors), for both ingestion and querying. Using different models would make similarity comparisons unreliable. Each embedding combines the study title, summary, eligibility criteria, and detailed description, giving the search the richest protocol context available in AACT.
doc["embed_text"] = f"{doc['brief_title']} {doc['summary']} {doc['eligibility_criteria']} {doc['detailed_description']}"The vector index builds over that field with a cosine metric after every embedding is stored, so it trains on the complete set.

With the index in place, a new protocol can be compared against all 1,688 stored studies in a single similarity call, which is the first stage of the recommender.
Ingesting the Graph
The structured data is loaded in stages: studies with their embed_text, facilities with performance metrics, investigators, and finally the relationships. Studies are linked to facilities by nct_id, and facilities to investigators by facility_id, turning shared IDs into directed edges in site_relationships.
Note: Duplicate investigators
One limitation is investigator data. AACT stores investigator names as free text without unique IDs, so the same person may appear as multiple nodes. This pipeline leaves those duplicates unresolved because site recommendations depend on study-to-site relationships, not investigator identities. If you plan to analyze investigators, you’ll need to resolve those names first.

This is the shape the recommender walks at query time: start from a study, step outward along the edges, and land on the facilities that ran it.
Adding Population Context by Geography
Two of the five variables, prevalence and demographics, are absent from AACT. They come from federal data keyed by county, not by trial, so they cannot ride the same ingest. src/enrich.py runs a second pass that joins them onto the site nodes through geography: for each US facility it reads the latitude and longitude already loaded from AACT, resolves the county through a geocoding lookup, and attaches that county’s cancer prevalence from CDC PLACES alongside a median-age signal.
county_fips = latlon_to_fips(fac["latitude"], fac["longitude"])
db.collection("facilities").update({
"_key": fac["_key"],
"county_fips": county_fips,
"cancer_prevalence": prevalence.get(county_fips), # real, CDC PLACES
"median_age": synthetic_median_age(county_fips), # generated
})A site stops being a bare location and gains a catchment, the population a feasibility team actually recruits from.

Note: Real and generated population data
This join runs on geography, not nct_id. Prevalence is real CDC data; the age signal is generated here, the second synthesized layer, and flagged as such. Sites outside the United States keep their other variables and surface with lower confidence rather than dropping out, which turns four provisional variables into the working set the query ranks on.
Ingesting Protocol Documents via AutoGraph
Protocol text follows a separate ingestion path. AutoGraph works with unstructured documents, so src/export_docs.py first converts each study’s structured fields, summary, eligibility criteria, and intervention text into plain text documents. That folder is then uploaded to a new AutoGraph project to start the pipeline. For a corpus of this size, the default import settings are sufficient.
AutoGraph then analyzes the documents and determines the best way to process them. It samples the corpus, identifies the knowledge domains it contains, and evaluates their complexity. In this case, the breast cancer protocols form a rich, focused dataset, so AutoGraph groups them into a single cluster and builds a full graph of entities and relationships rather than using a simpler vector-only approach.
Once the build completes, the Context Graph dashboard shows what was created. The corpus graph holds the 50 protocol documents in a single cluster, and the knowledge graph extracted from them contains 2,412 entities and 6,187 relationships. This gives you a clear view of the structure that now backs every query.

The result is a context graph built in three stages. A corpus graph captures the documents, a knowledge graph extracts the entities and the relationships between them, and a context graph layers on semantics, time, and traceability. Unlike a plain structured graph, it links clinical concepts across protocols, which is what lets the recommender explain why a site fits rather than just ranking it.

The Retriever shows the payoff. Ask it for dosage across the similar trials and it returns specific, cited values from the source material rather than an ungrounded summary, pulled from intervention text that the structured tables do not capture. The resolver runs this in Deep Search mode, which performs a multi-step search across the entire knowledge graph rather than a single neighborhood.

This goes beyond retrieving text. During ingestion, AutoGraph extracts structured information from each protocol against the domain ontology, identifying drugs, dosages, and their relationships and storing them as nodes and edges in a context graph. A detail like ribociclib at 600 mg once daily, 21 days on and 7 days off becomes a queryable fact linked to its source trial and backed by a citation. If the corpus contains no dosage, the Retriever says so rather than filling the gap, keeping the result grounded and giving GraphRAG a clear advantage over plain vector search.
The resolver then uses this context graph alongside the structured database, so dosage contributes directly to the recommendation alongside the four variables returned by the AQL query.
Recommending a Site Across Documents, Graph, and Vectors
The data layer is ready. Studies carry embeddings, facilities connect through the graph and hold performance and population data, and AutoGraph models the protocol text. A new protocol arrives, and src/resolver.py answers which sites fit best, and why.
The Marquee AQL Query
The query runs in two stages so the vector search keeps its own top-level SORT and LIMIT, which APPROX_NEAR_COSINE requires, with the traversal and scoring after it. The weights and the neutral defaults are passed in as bind parameters; their working values are listed in the comment at the top.
// Weights: protocol 0.35, site success 0.30, prevalence 0.20, age 0.15
// Neutral defaults for sites without geo data: prevalence 0.3, age 0.5
LET similar = (
FOR s IN studies
LET sim = APPROX_NEAR_COSINE(s.embedding, @vec)
SORT sim DESC
LIMIT 5
RETURN { nct_id: s.nct_id, title: s.brief_title, similarity: sim }
)
LET sites = (
FOR study IN similar
FOR v IN 1..1 OUTBOUND CONCAT("studies/", study.nct_id) GRAPH "site_topology"
FILTER IS_SAME_COLLECTION("facilities", v)
FILTER v.success_score != null
LET has_geo = v.cancer_prevalence != null
LET prevalence_score = has_geo ? (v.cancer_prevalence / 15) : @neutral_prev
LET age_score = (has_geo AND v.median_age != null)
? ((50 - ABS(v.median_age - 40)) / 50) : @neutral_age
LET composite = (study.similarity * @w_protocol)
+ (v.success_score * @w_perf)
+ (prevalence_score * @w_prev)
+ (age_score * @w_age)
COLLECT
name = v.name, city = v.city, country = v.country,
success_score = v.success_score, prevalence = v.cancer_prevalence,
median_age = v.median_age, has_geo_data = has_geo
AGGREGATE score = MAX(composite),
via_similarity = MAX(study.similarity),
prev_sub = MAX(prevalence_score),
age_sub = MAX(age_score)
SORT score DESC
LIMIT 10
RETURN {
name, city, country, success_score, prevalence, median_age, has_geo_data,
composite_score: score, matched_similarity: via_similarity,
prevalence_score: prev_sub, age_score: age_sub
}
)
RETURN { similar_studies: similar, recommended_sites: sites }APPROX_NEAR_COSINE compares the incoming protocol against every stored one and returns the five closest matches. The @vec bind parameter is that protocol’s embedding, which resolver.py generates before the query runs. The graph then takes a single hop from each matched study to the facilities that ran it, since the site_topology edges point from studies to facilities. CONCAT("studies/", study.nct_id) builds each document handle inline, which works because a study’s key is its NCT ID.
Sites are ranked on a weighted score across protocol similarity, site performance, population data, and trial history, and the query returns each sub-score so the ranking can be broken down rather than taken on faith. Where population data is missing, neutral defaults keep a promising site in the running at lower confidence; the median_age guard ensures a site with geo data but no age value falls back to that neutral value instead of scoring zero.
Note: Check vector search on your version
One caveat: APPROX_NEAR_COSINE must sit in a top-level SORT with a LIMIT, and mixing vector search with extra filters can change behavior by the Arango version, so verify it on your release. The point of a multimodel database isn’t that everything runs in one query, but that vector search, graph traversal, and structured lookups work together without stitching results across separate systems.

The same query runs from src/resolver.py, which embeds the incoming protocol, passes the vector in as the @vec bind parameter, and formats the ranked result.
Firing a Placement
Run the resolver against the sample protocol, a Phase III study of palbociclib plus endocrine therapy in HR-positive, HER2-negative breast cancer:
python src/resolver.py data/protocol_sample.jsonIt returns four parts: the ranked sites, the evidence behind them, the reasoning, and a confidence signal.
=== RECOMMENDED SITES ===
Weights: protocol 35% · success 30% · prevalence 20% · age 15%
1. Avera Cancer Institute-Aberdeen -- Aberdeen, US overall 0.847
protocol 0.826 · success 0.97 · prevalence 0.62 · age 0.956
2. Marin Cancer Center -- Greenbrae, US overall 0.845
protocol 0.826 · success 0.91 · prevalence 0.70 · age 0.95
3. CAMC Cancer Center -- Charleston, US overall 0.844
protocol 0.826 · success 0.92 · prevalence 0.66 · age 0.982
=== EVIDENCE PASSAGES ===
- PALbociclib CoLlaborative Adjuvant Study (0.826)
- A Real-life Study ... Palbociclib in US Patients With Breast Cancer (0.81)
- Abemaciclib, Palbociclib or Dalpiciclib ... HR+/HER2- Metastatic Breast Cancer (0.809)
=== DOSAGE EVIDENCE (AutoGraph context graph) ===
- Abemaciclib: 150 mg BID, orally, 20-24 weeks (with Z-endoxifen or ARV-471)
- Ribociclib: 600 mg once daily, 21 on / 7 off (28-day cycle)
- Capivasertib: 400 mg twice daily, 4 on / 3 off
(3 distinct dosage statements)
=== REASONING ===
Avera leads on site success (0.97), outweighing Marin's stronger prevalence.
CAMC's age match isn't enough to close its gap. All sites carry geo data.
=== CONFIDENCE ===
high -- strong match, sufficient sites, complete data
=== RETRIEVAL STATS ===
top similarity : 0.826 · vector hits : 5 · depth : 1 · sites : 10 · time : 22.0sThe confidence score reflects the quality of the recommendation. It drops when protocol matches are weak, too few similar studies are found, population data is missing, or dosage context is unavailable. When confidence is low, the system makes that clear instead of overstating the result.
Why the Semantic Match Matters
The strongest evidence comes from the top matching protocol. Although the sample mentions only palbociclib, its closest match, with a score of 0.809, is a trial involving three CDK4/6 inhibitors with endocrine therapy in the same patient population. The match is driven by shared clinical meaning, including drug class, patient population, and treatment approach, rather than exact keyword overlap.
A graph database such as Neo4j can connect studies to sites and traverse those relationships well. What it cannot do on its own is judge how closely a new protocol matches past trials in meaning and fold that judgment into the same traversal. This is where Arango AutoGraph adds value. Vector search and graph traversal run together in the same query engine, allowing the system to combine semantic similarity with each site’s connections to relevant trial history. There is no separate vector store to manage or keep in sync. The complete ten-site recommendation is available in data/recommendation_output.json.
Ask Ada in Plain English
Ada, Arango’s AI Digital Assistant, can query the same data without writing AQL. For example, ask:
“Which facilities in the United States have a cancer prevalence above 8 and a median age over 40? Show name, city, prevalence, and age.”
Ada plans the query, runs it against the same enriched data the Python resolver uses, and returns the results in plain language, here as a table of matching US facilities.

The same contextual layer serves both a Python resolver and a natural-language question from a program lead who has never written AQL. One store, two front doors.
Gotchas, What’s Next, and Conclusion
Before applying this approach to real trial data, there are a few limitations to keep in mind.
- Two parts of the data are currently synthetic: site performance and patient age. In production, these should be replaced with real operational and demographic data. Prevalence, however, already comes from CDC data, so that source can remain unchanged. The ranking logic does not need to change, only the underlying data sources.
- Population data currently covers US sites only. International sites without corresponding prevalence or age data remain in the results, but their recommendations carry lower confidence.
- Investigator matching is another limitation. AACT stores investigator names as free text rather than stable identifiers, so a production system would need proper entity resolution before investigator relationships could be used reliably.
- Finally, the context graph was built from 50 protocols. Scaling this to a larger corpus will require tuning the ingestion and processing pipeline, as well as validating vector search behavior against the target Arango version.
What’s Next
Replace the generated layers with real operational and census data, since even partial signals like activation timelines can improve ranking. Expand the AutoGraph corpus to support richer entity extraction, and swap the current working set of five metrics for your organization’s own variables as they become available.
A future step is turning src/resolver.py into an agent tool that can run a full feasibility workflow: protocol intake, site shortlisting, investigator verification, and activation planning.
Conclusion
A single Arango deployment brings the named graph, vector search, and AutoGraph context graph together in one workflow. Semantic similarity identifies relevant trials, graph relationships connect sites to their trial history, structured fields add the population context, and dosage insights come from the extracted protocol text. Each recommendation is returned with supporting evidence and a confidence score.
The real advantage is how these pieces work together. Because they run against a single store, there is nothing to move between systems or stitch together afterward, and every recommendation resolves in one query. You can clone the repo, run the notebook, and adapt the workflow to your own trial data.
Additional Resources
docs.arango.ai
Vector indexes and AQL vector functions
docs.arango.ai
docs.arango.ai
docs.arango.ai
arango.ai