RAGPoison is a reproducible MovieLens 100K platform for studying retrieval poisoning effects on recommendations, traces, and evaluation metrics across baseline and attacked indices.
- Overview
- Quickstart
- Prerequisites
- Installation
- Configuration
- Running the project
- Project layout
- Usage
- Development
- CI and releases
- Troubleshooting
This repository implements an end-to-end poisoning research workflow on MovieLens data: data prep, poisoned bulk generation, Elasticsearch indexing, API access, UI visualization, and metric/report generation.
[Sources: api/app/data/preprocess.py, agent/datasets/poison_builder.py, api/app/services/indexing_service.py, api/app/main.py, api/app/eval/runner.py, api/app/eval/reporting.py]
- FastAPI backend serving API routes under
/apiplus SPA/static fallback.
[Source: api/app/main.py] - Recommendation and trace APIs that switch between
moviesandmovies_poisonedindices by mode (baselineorattacked).
[Sources: api/app/routers/recs.py, api/app/routers/trace.py, api/app/services/recs_service.py] - Configurable victim/attacker LLM roles and ranking mode (
deterministicorllm_rerank).
[Sources: common/schemas/llm_config.py, api/app/routers/settings_llm.py, api/app/services/recs_service.py] - Interactive CLI wizard and non-interactive commands for data, attack, indexing, evaluation, reports, and model-catalog refresh.
[Sources: api/app/cli/cli.py, api/app/cli/wizard.py, api/app/cli/commands_data.py, api/app/cli/commands_attack.py, api/app/cli/commands_index.py, api/app/cli/commands_eval.py, api/app/cli/commands_report.py, api/app/cli/commands_llm.py] - React + Vite frontend for user selection, dashboard, and settings.
[Sources: web/package.json, web/src/main.tsx, web/src/pages/UserSelect.tsx, web/src/pages/Dashboard.tsx, web/src/pages/Settings.tsx] - Python SDK client for typed API consumption.
[Sources: sdk/python/ragpoison_sdk/client.py, sdk/python/ragpoison_sdk/types.py]
api/: application service layer (routers, services, CLI, evaluation).agent/: poisoning logic and poisoned bulk writer.rag/: recommendation candidate generation, ranking, explanations, and trace shaping.web/: frontend UI.docker/: compose stack, mappings, and indexing scripts.sdk/python/: Python client.data/andml-100/: runtime dataset/config/results locations (bind-mounted in compose).
[Sources: docker/docker-compose.yml, api/app/services/recs_service.py, agent/datasets/poison_builder.py, rag/recsys/candidate_gen.py, sdk/python/ragpoison_sdk/client.py, api/app/data/paths.py]
The shortest path to a working local stack:
Run the following commands from the repository root. If you are inside a subdirectory such as sdk/python, pass the correct relative project path (for example uv run --project ../../api ...).
# 1) Prepare processed outputs from MovieLens files.
uv run --project api python -m api.app.cli.cli data prepare
# 2) Start Elasticsearch, Kibana, Ollama, and app.
docker compose -f docker/docker-compose.yml up -d --build
# 3) Build baseline + poisoned indices.
docker compose -f docker/docker-compose.yml --profile indexing run --rm indexer
# 4) Verify backend health.
curl -s http://localhost:8000/api/healthOpen:
- App:
http://localhost:8000 - Kibana:
http://localhost:5601
[Sources: api/app/cli/commands_data.py, docker/docker-compose.yml, api/app/routers/health.py, tools/dev_notes.md]
- Python
>=3.12for backend and SDK.
[Sources: api/pyproject.toml, sdk/python/pyproject.toml] uvfor Python dependency management and command execution in this repo.
[Sources: Dockerfile, tools/dev_notes.md]- Docker Engine + Docker Compose plugin for full stack services (
elasticsearch,kibana,ollama,app, optionalindexer).
[Source: docker/docker-compose.yml] - Node/npm for frontend local workflows (
dev,build,typecheck,preview).
[Source: web/package.json]
Service roles in the default stack:
- Elasticsearch: retrieval/index storage.
- Ollama: local model endpoint.
- Kibana: index inspection UI.
- App: FastAPI backend + static SPA serving.
[Sources: docker/docker-compose.yml, api/app/main.py, api/app/settings.py]
uv sync --project api --frozen[Sources: api/pyproject.toml, api/uv.lock, Dockerfile]
uv sync --project sdk/python --frozen[Sources: sdk/python/pyproject.toml, sdk/python/uv.lock]
npm --prefix web install[Source: web/package.json]
npm --prefix web run build[Source: web/package.json]
docker build -t ragpoison:dev -f Dockerfile .[Source: Dockerfile]
| Name | Required | Default | Description | Where used |
|---|---|---|---|---|
ELASTICSEARCH_URL |
No | http://localhost:9200 |
Elasticsearch base URL for app/indexing in host mode. Compose app service overrides this to http://elasticsearch:9200 for container DNS. |
api/app/settings.py, api/app/services/indexing_service.py, docker/docker-compose.yml |
OLLAMA_BASE_URL |
No | http://localhost:11434 |
Local Ollama base URL (host mode default). Docker compose app service overrides this to http://ollama:11434. |
api/app/settings.py, docker/docker-compose.yml |
OLLAMA_TIMEOUT_SECONDS |
No | 60 |
Timeout for local Ollama generation requests. Increase this if llm_rerank prompts time out on slower CPUs. |
api/app/settings.py, api/app/llm/registry.py, api/app/llm/local_ollama.py |
OPENAI_COMPAT_BASE_URL |
No | none | Optional shared OpenAI-compatible gateway base URL (transit). Used as fallback for ChatGPT base URL. | api/app/settings.py, api/app/llm/credentials.py, docker/docker-compose.yml |
OPENAI_COMPAT_API_KEY |
Conditional | none | Optional shared transit key for OpenAI-compatible providers (chatgpt, claude, gemini) when provider-specific key is unset. |
api/app/settings.py, api/app/llm/credentials.py, docker/docker-compose.yml |
CHATGPT_BASE_URL |
No | https://api.openai.com/v1 |
ChatGPT/OpenAI-compatible base URL override. | api/app/settings.py, api/app/llm/credentials.py, api/app/llm/providers_chatgpt.py, docker/docker-compose.yml |
CHATGPT_API_KEY |
Conditional | none | Primary ChatGPT API key env var. | api/app/settings.py, api/app/llm/credentials.py, api/app/llm/providers_chatgpt.py, docker/docker-compose.yml |
CLAUDE_BASE_URL |
No | https://api.anthropic.com/v1 |
Claude Messages API base URL override. | api/app/settings.py, api/app/llm/credentials.py, docker/docker-compose.yml |
CLAUDE_API_KEY |
Conditional | none | Primary Claude API key env var. | api/app/settings.py, api/app/llm/credentials.py, docker/docker-compose.yml |
GEMINI_BASE_URL |
No | https://generativelanguage.googleapis.com/v1beta |
Gemini Generative Language API base URL override. | api/app/settings.py, api/app/llm/credentials.py, docker/docker-compose.yml |
GEMINI_API_KEY |
Conditional | none | Primary Gemini API key env var. | api/app/settings.py, api/app/llm/credentials.py, docker/docker-compose.yml |
QWEN_BASE_URL |
No | https://dashscope.aliyuncs.com/compatible-mode/v1 |
Qwen OpenAI-compatible base URL override. | api/app/settings.py, api/app/llm/credentials.py, docker/docker-compose.yml |
QWEN_API_KEY |
Conditional | none | Primary Qwen API key env var for standalone Qwen access. | api/app/settings.py, api/app/llm/credentials.py, docker/docker-compose.yml |
DATA_ROOT |
No | auto-resolved | Optional override for data root path. | api/app/settings.py |
CONFIG_ROOT |
No | auto-resolved | Optional override for config directory. | api/app/settings.py |
PROCESSED_ROOT |
No | auto-resolved | Optional override for processed outputs directory. | api/app/settings.py |
STATIC_ROOT |
No | api/app/static |
Optional override for served SPA/static root. | api/app/settings.py, api/app/main.py |
LLM_MODELS_FILE |
No | conf/llm_models.yaml |
Optional override for curated cloud model list file. Refresh it with llm refresh-models when official provider catalogs change. |
api/app/settings.py, api/app/llm/registry.py, api/app/cli/commands_llm.py |
| Name | Required | Default | Description | Where used |
|---|---|---|---|---|
ELASTICSEARCH_IMAGE |
No | elasticsearch:8.19.11 |
Elasticsearch image tag override. | docker/docker-compose.yml, docker/.env.example |
KIBANA_IMAGE |
No | kibana:8.19.11 |
Kibana image tag override. | docker/docker-compose.yml, docker/.env.example |
OLLAMA_IMAGE |
No | ollama/ollama:latest |
Ollama image tag override. | docker/docker-compose.yml, docker/.env.example |
| Name | Required | Default | Description | Where used |
|---|---|---|---|---|
ES_URL |
No | falls back to ELASTICSEARCH_URL |
Explicit ES URL override for shell index/wait scripts. | docker/scripts/wait-for-es.sh, docker/scripts/index_baseline.sh, docker/scripts/index_poisoned.sh |
MAPPING_FILE |
No | script default | Mapping file override for index scripts. | docker/scripts/index_baseline.sh, docker/scripts/index_poisoned.sh |
BULK_FILE |
No | script default | Bulk JSONL input override for index scripts. | docker/scripts/index_baseline.sh, docker/scripts/index_poisoned.sh |
ES_WAIT_RETRIES |
No | 60 |
Wait loop retry count for ES readiness script. | docker/scripts/wait-for-es.sh |
ES_WAIT_SLEEP_SECONDS |
No | 2 |
Wait loop sleep interval in seconds. | docker/scripts/wait-for-es.sh |
KIBANA_URL |
No | http://kibana:5601 |
Wizard Kibana health/status URL override. | api/app/cli/wizard.py |
RAGPOISON_API_URL |
No | http://localhost:8000 |
Smoke test API base override. | test/smoke/test_stack_up.py, test/smoke/test_recs_roundtrip.py |
data/config/llm_config.json: victim/attacker providers + models +ranking_mode. Read/write via settings API and wizard.
[Sources: api/app/routers/settings_llm.py, api/app/services/recs_service.py, api/app/cli/wizard.py, data/config/llm_config.json]data/config/attack_config.json: attack type and poisoning parameters.
[Sources: common/schemas/attack_config.py, agent/datasets/poison_builder.py, api/app/cli/wizard.py, data/config/attack_config.json]conf/llm_models.yaml: curated cloud model options by provider, generated from official provider APIs. Qwen entries are sourced from Aliyun China DashScope, not the global catalog.
[Sources: api/app/llm/registry.py, api/app/llm/model_catalog.py, conf/llm_models.yaml]docker/es/movies_index.jsonanddocker/es/movies_poisoned_index.json: index mapping payloads used by index workflows.
[Sources: api/app/services/indexing_service.py, docker/scripts/index_baseline.sh, docker/scripts/index_poisoned.sh]
- Env vars are the primary credential source. The app loads
.envby default and also supports.env.keyas an optional alias. - Compose reads provider keys from repo-root
.env/.env.key, with.env.keyoverriding.env;secrets/files are no longer used.
[Sources: docker/docker-compose.yml, api/app/settings.py] - Cloud providers are marked unavailable when no API key env configuration is present.
[Sources: api/app/llm/registry.py, api/app/routers/settings_llm.py] chatgpt,claude,gemini, andqwenall have real generate paths when their env keys are configured.
[Sources: api/app/llm/providers_chatgpt.py, api/app/llm/providers_claude.py, api/app/llm/providers_gemini.py, api/app/llm/providers_qwen.py]
- Copy
.env.exampleto.env. - Set provider keys in
.env(CHATGPT_API_KEY,CLAUDE_API_KEY,GEMINI_API_KEY,QWEN_API_KEY) and optional transit vars (OPENAI_COMPAT_BASE_URL,OPENAI_COMPAT_API_KEY). - Restart the app or compose stack after updating keys so the backend reloads the env-backed settings snapshot.
Run API CLI help:
uv run --project api python -m api.app.cli.cli --helpRefresh the curated cloud model catalog from official provider APIs:
uv run --project api python -m api.app.cli.cli llm refresh-modelsRun API server directly:
uv run --project api uvicorn api.app.main:app --host 0.0.0.0 --port 8000Run web dev server:
npm --prefix web run devOptional host-run wizard command (requires Elasticsearch published to host; see Optional host-run mode (dev) below):
ELASTICSEARCH_URL=http://localhost:9200 uv run --project api python -m api.app.cli.cli wizard[Sources: api/app/cli/cli.py, api/pyproject.toml, Dockerfile, web/package.json]
Start long-lived services:
docker compose -f docker/docker-compose.yml up -d --buildRun interactive workflow wizard (recommended, inside the app container):
docker compose -f docker/docker-compose.yml exec RagPoison uv run --project api python -m api.app.cli.cli wizardOptional host-run mode (dev, explicitly publish Elasticsearch):
docker compose -f docker/docker-compose.yml -f docker/docker-compose.dev.yml up -d --buildThen run host commands with ELASTICSEARCH_URL=http://localhost:9200.
Host mode (uv run on your machine) uses http://localhost:11434 and requires Ollama reachable on that port. Compose publishes Ollama only on 127.0.0.1:11434 for host access, while services inside the Docker network use http://ollama:11434.
Run one-shot indexer profile:
docker compose -f docker/docker-compose.yml --profile indexing run --rm indexerStop services:
docker compose -f docker/docker-compose.yml downData persistence note:
../dataand../ml-100are bind mounts in compose.es_dataandollama_dataare named volumes.
[Sources: docker/docker-compose.yml, docker/docker-compose.dev.yml]
api/ FastAPI app, CLI, evaluation logic, settings
web/ React + Vite frontend
agent/ Poisoning transformations and bulk generation
rag/ Candidate generation, ranking, explanation, trace shaping
common/ Shared schemas
docker/ Compose stack, index mappings, helper scripts
sdk/python/ Python SDK client package
data/ Runtime config, processed outputs, results
ml-100/ MovieLens source files
[Sources: docker/docker-compose.yml, api/app/main.py, api/app/cli/cli.py, agent/datasets/poison_builder.py, rag/recsys/candidate_gen.py, sdk/python/ragpoison_sdk/client.py, api/app/data/paths.py]
Prepare data:
uv run --project api python -m api.app.cli.cli data prepareBuild poisoned bulk from baseline bulk:
uv run --project api python -m api.app.cli.cli attack build-poisonedIndex baseline + poisoned data and print stats:
uv run --project api python -m api.app.cli.cli index bothRun evaluation:
uv run --project api python -m api.app.cli.cli eval run --mode full --label demo_runGenerate reports for a run:
uv run --project api python -m api.app.cli.cli report generate --label demo_run[Sources: api/app/cli/commands_data.py, api/app/cli/commands_attack.py, api/app/cli/commands_index.py, api/app/cli/commands_eval.py, api/app/cli/commands_report.py]
Defined API routes:
GET /api/healthGET /api/usersGET /api/users/{user_id}/profileGET /api/users/{user_id}/history?split=train|allPOST /api/recommendationsPOST /api/traceGET /api/settings/llmPUT /api/settings/llmGET /api/settings/llm/options
[Sources: api/app/main.py, api/app/routers/health.py, api/app/routers/users.py, api/app/routers/recs.py, api/app/routers/trace.py, api/app/routers/settings_llm.py]
Example requests:
curl -s http://localhost:8000/api/users?limit=10
curl -s -X POST http://localhost:8000/api/recommendations \
-H 'Content-Type: application/json' \
-d '{"user_id":1,"mode":"baseline","k":10}'
curl -s -X POST http://localhost:8000/api/trace \
-H 'Content-Type: application/json' \
-d '{"user_id":1,"mode":"attacked","k_retrieval":20}'Request/response schemas are defined in common/schemas/api_types.py.
from ragpoison_sdk import RagPoisonClient
client = RagPoisonClient("http://localhost:8000")
users = client.list_users(limit=5)
recs = client.recommend(user_id=users[0].user_id, mode="baseline", k=10)
trace = client.trace(user_id=users[0].user_id, mode="attacked", k_retrieval=20)[Sources: sdk/python/ragpoison_sdk/client.py, sdk/python/ragpoison_sdk/types.py]
- Python dependencies include
ruffandmypy; no project-level tool configuration file is currently present in this repo root.
[Sources: api/pyproject.toml, repository file listing] - Web type checks are available via:
npm --prefix web run typecheck[Source: web/package.json]
Default test run (integration tests excluded by default marker expression):
uv run pytestRun integration tests explicitly:
uv run pytest -m integrationMarker and default selection behavior are defined in pytest.ini.
[Sources: pytest.ini, test/smoke/test_stack_up.py, test/smoke/test_recs_roundtrip.py]
- Use wizard
Environment checksfor dataset path, data write permissions, Elasticsearch/Kibana/Ollama reachability, and provider secret presence.
[Source: api/app/cli/wizard.py] - Use
index statsto verify index existence and document counts.
[Sources: api/app/cli/commands_index.py, api/app/services/indexing_service.py] - Use Kibana to inspect
movies*indices.
[Source: docker/docker-compose.yml]
503from user/profile/history/recommendation/trace endpoints can occur if required parquet files are missing. Rundata preparefirst.
[Sources: api/app/routers/users.py, api/app/routers/recs.py, api/app/routers/trace.py, api/app/services/users_service.py]- Recommendation/trace quality may look empty or unexpected if indices are missing or stale. Rebuild with
index bothor composeindexer.
[Sources: api/app/cli/commands_index.py, docker/docker-compose.yml] - Host shell DNS cannot resolve compose service names like
elasticsearchby default. Usehttp://elasticsearch:9200only inside compose containers; usehttp://localhost:9200on host when ES is published (for example viadocker/docker-compose.dev.yml).
[Sources: docker/docker-compose.yml, docker/docker-compose.dev.yml] eval runnow fails loudly when Elasticsearch retrieval is unreachable/misconfigured instead of silently falling back to local-only candidates. If you runuvon host, make sure Elasticsearch is published (docker compose -f docker/docker-compose.yml -f docker/docker-compose.dev.yml up -d --build) and indices are present (index both).
[Sources: api/app/eval/runner.py, api/app/services/recs_service.py, rag/recsys/candidate_gen.py]index resetrefuses to run without explicit confirmation flag:
uv run --project api python -m api.app.cli.cli index reset --yes[Source: api/app/cli/commands_index.py]
- Cloud providers can fail selection/save if key files are missing for the selected provider.
[Sources: api/app/routers/settings_llm.py, api/app/llm/registry.py]