Docker Image:
docker pull ghcr.io/onto-med/concept-graphs/concept-graphs-api:1.0.0
Concept Graphs is a Flask API for building, storing, inspecting, extending, and querying concept graphs from document corpora.
A corpus is processed through a pipeline:
- Preprocessing: load documents and extract noun chunks / phrase chunks.
- Embedding: encode extracted phrases into vectors.
- Clustering: group related phrases into concepts.
- Graph creation: build concept graphs from phrase/document relations.
- Optional integration: write graph-cluster metadata to an external vector store.
- Optional RAG: initialize retrieval-augmented generation over processed document chunks.
The API also supports process management, graph inspection, adding documents to existing graphs, deleting added document provenance, asking questions through RAG, and LLM-based query expansion.
The implementation is based on the Concept Graphs approach described in the references.
- Python
>=3.11,<3.12 uv- Optional external services:
- Marqo/vector store
- document index server
- RAG backend/chatter service, depending on configuration
Install dependencies:
uv syncRun checks:
uv run --group test ruff format .
uv run --group test ruff check .
uv run --no-sync pytest -qStart the Flask development server:
uv run python main.pyDefault local URL:
http://localhost:9010
Swagger UI / OpenAPI UI:
http://localhost:9010/
http://localhost:9010/openapi
The OpenAPI document lives at:
api/concept-graphs-api.yml
The app uses a Flask application factory:
from main import create_app
app = create_app()Production servers should call the factory, for example with Waitress:
waitress-serve --call --port=9007 main:create_appShared runtime state is attached to the Flask app under:
app.extensions["concept_graphs_context"]That context contains grouped runtime state for:
- running processes and threads
- active pipeline step objects
- file storage configuration
- per-process active RAG state
Build and start the default compose setup:
docker compose build
docker compose up -dThe production image runs Waitress with the factory entrypoint:
main:create_app --call
The production compose setup should use the code baked into the image. It should not bind-mount the project over /rest_api, because doing so can hide the image's build-time .venv.
The local development compose file may bind-mount the source tree for iterative work.
Typical API URL in Docker setups:
http://localhost:9007
Prompt profiles are normal YAML files below conf/:
conf/rag/localization/<profile>.yml
conf/query-expansion/localization/<profile>.yml
When using the production image, do not bind-mount the whole project over /rest_api. Instead, mount only the additional profile file or profile directory into the matching conf/ subdirectory.
Example custom RAG profile file on the host:
./local-conf/rag/localization/fr.yml
input_variables:
- summaries
- question
template: |
Réponds à la question à partir des SOURCES fournies.
Réponds toujours en français.
QUESTION:
{question}
SOURCES:
{summaries}
RÉPONSE FINALE EN FRANÇAIS:Compose example:
services:
concept-graphs-api:
image: ghcr.io/onto-med/concept-graphs/concept-graphs-api:1.0.0
volumes:
- ./local-conf/rag/localization/fr.yml:/rest_api/conf/rag/localization/fr.yml:ro
- ./local-conf/query-expansion/localization/fr.yml:/rest_api/conf/query-expansion/localization/fr.yml:rodocker run example:
docker run --rm -p 9007:9007 \
-v "$PWD/local-conf/rag/localization/fr.yml:/rest_api/conf/rag/localization/fr.yml:ro" \
ghcr.io/onto-med/concept-graphs/concept-graphs-api:1.0.0Use the mounted profile by passing the profile/language in the request, for example:
{
"language": "fr",
"prompt_template": {
"profile": "fr"
}
}For query expansion, use:
{
"language": "fr",
"prompt": {
"profile": "fr"
}
}Most endpoints use a process query parameter. A process represents one corpus and its stored artifacts.
If omitted, the process defaults to:
default
Example:
curl "http://localhost:9010/status?process=my_corpus"Pipeline artifacts are stored below the configured file storage directory, defaulting to:
tmp/
Each process has its own directory:
tmp/<process>/
Typical storage behavior:
| Artifact / data | Stored where |
|---|---|
| Pipeline step pickles | tmp/<process>/ |
| Graph pickle | tmp/<process>/<process>_graph.pickle |
| Phrase embeddings | local pickle and/or Marqo, depending on config |
| Integration metadata | Marqo/vector store when configured |
| Document-addition graph provenance | graph pickle node attributes |
| Document-addition vector provenance | Marqo entry metadata |
Full documents added through /graph/document/add |
not currently inserted into the external document server |
Document addition currently updates the graph/vector-store side only:
| Target | Added by document addition? |
|---|---|
| Concept graph pickle | yes |
| Vector store / Marqo phrase index | yes |
| Existing processed data pickle | no |
| External document index server | no |
Starts a full concept-graph pipeline.
The endpoint accepts either:
application/jsonmultipart/form-data
Documents can come from either:
- an uploaded zip file, or
- an external document server.
| Name | Type | Default | Description |
|---|---|---|---|
process |
string | default |
Corpus/process name. |
language |
string | en |
Document language, for example en or de. |
skip_present |
boolean | true |
Reuse already serialized step artifacts where possible. |
skip_steps |
string | Comma-separated steps to skip: data, embedding, clustering, graph, integration. |
|
return_statistics |
boolean | false |
If true, waits for completion and returns graph statistics. This can take a long time. |
curl -X POST "http://localhost:9010/pipeline?process=my_corpus&language=en" \
-H "Content-Type: application/json" \
-d @conf/pipeline-config_en.jsonA full JSON config has this general shape:
{
"name": "my_corpus",
"language": "en",
"document_server": {
"url": "http://localhost",
"port": 9008,
"index": "documents",
"size": 30,
"other_id": "id",
"label_key": "label",
"replace_keys": {
"text": "content"
}
},
"vectorstore_server": {
"url": "http://localhost",
"port": 8882
},
"config": {
"data": {},
"embedding": {},
"clustering": {},
"graph": {}
}
}curl -X POST "http://localhost:9010/pipeline?process=my_corpus&language=en&skip_present=true" \
-F data=@"./documents.zip" \
-F data_config=@"./data-config.yaml" \
-F embedding_config=@"./embedding-config.yaml" \
-F clustering_config=@"./clustering-config.yaml" \
-F graph_config=@"./graph-config.yaml" \
-F vectorstore_server_config=@"./vectorstore-server.yaml"Supported multipart fields:
| Field | Required | Description |
|---|---|---|
data |
conditionally | Zip file containing input text documents. Required unless a document server config is provided. |
document_server_config |
conditionally | Config file for loading documents from an external document server. Required unless uploaded data is provided. |
vectorstore_server_config |
no | External vector-store config. |
labels |
no | Optional labels mapping file. |
data_config |
no | Preprocessing config. |
embedding_config |
no | Embedding config. |
clustering_config |
no | Clustering config. |
graph_config |
no | Graph creation config. |
Get the default configuration:
curl "http://localhost:9010/pipeline/configuration?default=true&language=en"Get a stored process configuration:
curl "http://localhost:9010/pipeline/configuration?default=false&process=my_corpus"Available defaults are stored in conf/, for example:
conf/pipeline-config_de.json
curl "http://localhost:9010/preprocessing/statistics?process=my_corpus"
curl "http://localhost:9010/preprocessing/noun_chunks?process=my_corpus"curl "http://localhost:9010/embedding/statistics?process=my_corpus"curl "http://localhost:9010/clustering/concepts?process=my_corpus&top_k=15&distance=0.6"Parameters:
| Name | Type | Default | Description |
|---|---|---|---|
top_k |
integer | 15 |
Number of representative phrases per concept. |
distance |
number | 0.6 |
Cosine distance threshold. |
curl "http://localhost:9010/graph/statistics?process=my_corpus"
curl "http://localhost:9010/graph/0?process=my_corpus"
curl "http://localhost:9010/graph/0?process=my_corpus&draw=true"Parameters:
| Name | Type | Default | Description |
|---|---|---|---|
process |
string | default |
Process name. |
draw |
boolean | false |
Return a rendered graph where supported. |
Adds one or more documents to an existing process and integrates their phrases into already built graphs.
curl -X POST "http://localhost:9010/graph/document/add?process=my_corpus" \
-H "Content-Type: application/json" \
-d '{
"language": "en",
"documents": [
{
"id": "doc-001",
"name": "example.txt",
"content": "The document text goes here.",
"label": "optional-label"
}
],
"vectorstore_server": {
"url": "http://localhost",
"port": 8882
}
}'Request fields:
| Field | Type | Required | Description |
|---|---|---|---|
language |
string | yes | Document language. |
documents |
array | yes | Documents to add. |
documents[].id |
string | no | External document ID. If missing, a UUID is generated. |
documents[].name |
string | yes | Document name. |
documents[].content |
string | yes | Document text. |
documents[].label |
string | no | Optional document label. |
vectorstore_server |
object | no | Vector-store connection settings. |
document_server |
object | no | Currently not used for inserting added documents. |
The endpoint starts an asynchronous document-addition thread.
Check the result:
curl "http://localhost:9010/graph/document/add/status?process=my_corpus"Document-addition provenance is stored as:
- graph node
documentsattributes in the graph pickle documentsmetadata on Marqo/vector-store entries
Example Marqo provenance metadata:
{
"documents": [
{
"id": "doc-001",
"offsets": [[0, 42]]
}
],
"source": "document_addition"
}Removes document provenance from graphs and, where vector-store provenance is available, from Marqo entries.
curl -X DELETE "http://localhost:9010/graph/document/doc-001?process=my_corpus"Optional query parameters:
| Name | Type | Default | Description |
|---|---|---|---|
remove_unreferenced_nodes |
boolean | true |
Remove graph nodes that have no document references after deletion. |
delete_unreferenced_embeddings |
boolean | false |
Delete vector-store entries that have no document provenance after deletion. |
Optional JSON body if vector-store settings are not available from the saved embedding object:
{
"vectorstore_server": {
"client_url": "http://localhost:8882",
"index_name": "my_corpus"
}
}List known/running processes:
curl "http://localhost:9010/processes"Get one process status:
curl "http://localhost:9010/status?process=my_corpus"Stop a process:
curl "http://localhost:9010/processes/my_corpus/stop?hard_stop=false"Delete a process and serialized artifacts:
curl -X DELETE "http://localhost:9010/processes/my_corpus/delete?hard_stop=false"Check whether a configured document server is reachable and contains data.
JSON request:
curl -X POST "http://localhost:9010/status/document-server" \
-H "Content-Type: application/json" \
-d '{
"url": "http://localhost",
"port": 9008,
"index": "documents",
"size": 30
}'Multipart request:
curl -X POST "http://localhost:9010/status/document-server" \
-F document_server_config=@"./document-server-config.yaml"Typical document-server config:
url: "http://localhost"
port: 9008
index: "documents"
size: 30
other_id: "id"
label_key: "label"
replace_keys:
text: contentThe API can initialize one active RAG component per process/corpus. Each process uses its own RAG vector-store index named <process>_rag.
curl -X POST "http://localhost:9010/rag/init?process=my_corpus&force=false" \
-H "Content-Type: application/json" \
-d '{
"api_key": "",
"language": "en",
"vectorstore_server": {
"url": "http://localhost",
"port": 8882
},
"chatter": {
"chatter": "src.rag.chatters.blablador.BlabladorChatter"
},
"prompt_template": {
"profile": "en"
}
}'Provider-specific OpenAI-compatible request options can be forwarded through the chatter config with extra_body, for example to disable model-specific thinking/reasoning when the provider supports it:
{
"chatter": {
"model": "alias-fast",
"temperature": 0.0,
"extra_body": {
"chat_template_kwargs": {
"enable_thinking": false
}
}
}
}The exact extra_body shape is provider/model-specific.
RAG prompt profiles are file-based by default:
conf/rag/localization/
en.yml
de.yml
Each profile contains a template and input_variables. The built-in variables are {question} and {summaries}. The old inline request-body shape with templates and input_variables is still supported for experiments/backwards compatibility; prompt_template.template can also override the resolved file profile directly.
Query parameters:
| Name | Type | Default | Description |
|---|---|---|---|
process |
string | default |
Process name. |
force |
boolean | false |
Reinitialize/refill the vector-store index even if it already exists. |
If the backing chunk vector store is empty, initialization starts a background task. Check readiness with:
curl "http://localhost:9010/status/rag?process=my_corpus"curl "http://localhost:9010/rag/question?process=my_corpus&q=What%20is%20this%20corpus%20about%3F"Use POST to restrict retrieval to selected document IDs and/or set a chunk limit:
curl -X POST "http://localhost:9010/rag/question?process=my_corpus&q=What%20does%20document%20A%20say%3F" \
-H "Content-Type: application/json" \
-d '{
"doc_ids": ["doc-001", "doc-002"],
"limit": 15
}'Request body fields:
| Field | Type | Default | Description |
|---|---|---|---|
doc_ids |
array of strings | [] |
Restrict retrieval to selected document IDs. |
limit |
integer | 15 |
Maximum number of chunks to retrieve. |
Successful response:
{
"answer": "...",
"info": "..."
}The API can generate categorized LLM query-expansion candidates and optionally ground them against terminology/source files.
Endpoint:
POST /query-expansion
The default implementation uses LangChain and validates the LLM output with Pydantic models. A PydanticAI generator remains available for future/custom use, but is not the default runtime path.
Supported built-in categories are stable API identifiers:
synonym
medication
diagnosis
symptom
procedure
abbreviation
broader_term
narrower_term
related_term
Prompt profiles live in:
conf/query-expansion/localization/
en.yml
de.yml
The profile is selected from prompt.profile or, if omitted, from language. Prompt templates and category descriptions can also be overridden per request.
Prefer passing provider tokens through a header instead of the JSON body:
curl -X POST "http://localhost:9010/query-expansion" \
-H "Content-Type: application/json" \
-H "X-LLM-API-Key: YOUR_BLABLADOR_API_KEY" \
-d '{
"term": "Myokardinfarkt",
"language": "de",
"categories": ["synonym", "medication", "symptom"],
"limit_per_category": 5,
"llm": {
"model": "alias-fast",
"options": {
"provider": "blablador",
"base_url": "https://api.helmholtz-blablador.fz-juelich.de/v1/",
"temperature": 0.0
}
},
"grounding": {
"include_llm_only": true
}
}'Accepted API-key headers include:
Authorization: Bearer <token>
X-LLM-API-Key: <token>
X-API-Key: <token>
X-Blablador-API-Key: <token>
{
"term": "Myokardinfarkt",
"language": "de",
"categories": ["synonym"],
"llm": {
"model": "alias-fast",
"options": {
"provider": "blablador"
}
},
"prompt": {
"profile": "de",
"category_descriptions": {
"synonym": "Synonyme, laienverständliche Begriffe und häufige Schreibvarianten."
}
}
}Grounding sources are optional. Currently implemented source type:
{
"name": "local-medical-terms",
"type": "local",
"path": "conf/query-expansion/grounding/medical_terms.example.yml"
}Local grounding loads YAML or JSON and exact-matches generated candidates against each entry's term and synonyms after lowercasing and whitespace normalization. Optional category / categories metadata restricts grounding to matching stable category IDs.
Example YAML:
terms:
- id: C001
term: Myokardinfarkt
synonyms:
- Herzinfarkt
- MI
category: diagnosis
- id: C002
term: Aspirin
synonyms:
- ASS
- Acetylsalicylsäure
category: medication
- id: C003
term: Brustschmerz
synonyms:
- Thoraxschmerz
categories:
- symptom
- related_termIf category / categories is omitted, the entry can ground candidates from any category. Ungrounded candidates are returned with status llm_only unless disabled via grounding options.
A helper script can run the pipeline on a folder of documents and write fixtures:
uv run --no-sync python test/data/scripts/run_pipeline_on_folder.py \
test/data/documents/grascco/ \
--process grascco \
--language de \
--file-storage-dir test/data/results \
--pipeline-config conf/pipeline-config_de.json \
--skip-steps integrationUseful flags:
| Flag | Description |
|---|---|
--pipeline-config |
Full JSON pipeline config. |
--skip-present |
Reuse existing artifacts. This is the default. |
--no-skip-present |
Recompute artifacts. |
--skip-steps integration |
Skip external vector-store integration. |
Current tests use fixtures under:
test/data/results/grascco
Business endpoints are documented in Swagger UI and api/concept-graphs-api.yml.
Main endpoint groups:
| Group | Endpoints |
|---|---|
| Pipeline | POST /pipeline, GET /pipeline/configuration |
| Artifacts | /preprocessing/*, /embedding/*, /clustering/*, /graph/* |
| Graph documents | POST /graph/document/add, GET /graph/document/add/status, DELETE /graph/document/{document_id} |
| Processes | GET /processes, GET /status, GET /processes/{process}/stop, DELETE /processes/{process}/delete |
| Status | POST /status/document-server, GET /status/rag |
| RAG | POST /rag/init, GET/POST /rag/question |
| Query expansion | POST /query-expansion |
Documentation/static routes such as /, /openapi, and static files are intentionally not considered business API endpoints.
Code style and linting use Ruff:
uv run --group test ruff format .
uv run --group test ruff check .Current Ruff rule groups include:
E: pycodestyle errorsF: pyflakesI: import sortingUP: pyupgrade modernization
Run the full validation set:
uv run --group test ruff format .
uv run --group test ruff check .
uv run --no-sync python -m compileall -q main.py src test
uv run --no-sync pytest -q- Pipeline execution can take a long time for large corpora.
- Some operations are asynchronous. Use
/status,/processes,/graph/document/add/status, and/status/ragto inspect progress. - RAG state is held in memory per process; it must be reinitialized after an application restart.
- Document addition does not currently insert full documents into the external document server.
- Graph quality depends heavily on corpus size, extracted phrase quality, embedding model, and clustering settings.
- Very small corpora may not produce useful concept clusters or graphs.
[1] Matthies, F. et al. Concept Graphs: A Novel Approach for Textual Analysis of Medical Documents. In: Röhrig, R. et al., editors. Studies in Health Technology and Informatics. IOS Press; 2023. Available from: https://ebooks.iospress.nl/doi/10.3233/SHTI230710
[2] NetworkX: https://networkx.org/
[3] Dianati, N. Unwinding the hairball graph: Pruning algorithms for weighted complex networks. Physical Review E. 2016;93(1). Available from: https://link.aps.org/doi/10.1103/PhysRevE.93.012304
[4] Chapman, Bridewell, Hanbury, Cooper, Buchanan. NegEx - A Simple Algorithm for Identifying Negated Findings and Diseases in Discharge Summaries. https://doi.org/10.1006/jbin.2001.1029