researcher
by mareurs
README.md
# Researcher
> Fast AI research agent in Rust — plans sub-questions, searches the web, scrapes sources in parallel, and writes a comprehensive markdown report.
```
query → planner (LLM) → search+scrape×N → quality filter → dedup → rerank → summarize×M → report (LLM)
```
**Why Rust?** No GIL — true parallel scraping, concurrent LLM summarization, ~5MB static binary, zero LangChain.
## Features
- **Multi-stage pipeline** — LLM-driven query planning, parallel web crawling, concurrent summarization, final report synthesis
- **Any OpenAI-compatible LLM** — local (llama.cpp, Ollama, vLLM) or cloud (OpenAI, Anthropic via LiteLLM)
- **Dual-model routing** — optionally route different pipeline stages to different model backends
- **Semantic deduplication** — TEI embeddings + cosine similarity drop near-duplicate sources before summarization
- **Cross-encoder reranking** — `ms-marco-MiniLM` scores and reranks sources by relevance, authority, and content quality
- **Domain profiles** — pin searches to curated source lists (tech-news, academic, llm-news, shopping, travel, news)
- **6 MCP tools** — `research`, `research_person`, `research_company`, `research_code`, `search_jobs`, `market_insight`
- **Streaming HTTP API** — SSE token stream for the web UI; blocking JSON for MCP and programmatic use
- **Job search** — finds remote jobs matching your `profiles.toml` preferences, with optional deep company briefs
## Architecture
```
topic
│
▼
Planner (LLM) ──── generates N sub-questions
│
▼
Crawler (parallel per query)
├─ SearXNG search (→ DuckDuckGo fallback)
└─ scrape URLs concurrently (reqwest + scraper crate)
│
▼
Quality filter ──── min word count, text density
│
▼
Dedup (TEI embed → cosine sim) ──── optional, requires EMBED_BASE_URL
│
▼
Cross-encoder rerank (TEI) ──── optional, requires RERANK_BASE_URL
│
▼
Summarizer (LLM, join_all — all calls concurrent)
│
▼
Publisher (LLM) ──── final markdown report / streaming tokens
```
Two binaries:
- **`researcher`** — HTTP server (`POST /research`, `POST /research/stream`, `GET /`) + CLI (`--query`)
- **`researcher-mcp`** — MCP stdio server for Claude Desktop / Claude Code
## Requirements
| Component | Required | Notes |
|-----------|----------|-------|
| **Rust 1.80+** | For building from source | `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \| sh` |
| **Docker + Compose** | For the full stack | v2.20+ recommended |
| **LLM backend** | One of below | |
| └ NVIDIA GPU | For local llama.cpp | Any CUDA-capable card with ≥8GB VRAM |
| └ AMD GPU | For ROCm llama.cpp | RDNA2/RDNA3, kernel 6.x |
| └ OpenAI API key | Cloud alternative | No GPU needed |
| └ Ollama | Local alternative | CPU or GPU |
| **SearXNG** | Bundled in infra stack | Private metasearch engine |
| **TEI** (optional) | For dedup + reranking | CPU-only images work fine |
## Quick Start
### Option A — Full local stack (NVIDIA GPU)
```bash
# 1. Clone and configure
git clone https://github.com/your-org/researcher.git
cd researcher
cp .env.example .env
cp infra/.env.example infra/.env
# Edit infra/.env: set LLAMA_MODELS_PATH to where your GGUF files live
# 2. Download a model (Qwen3.5-4B — ~3GB VRAM, works great)
huggingface-cli download unsloth/Qwen3.5-4B-GGUF \
--include "Qwen3.5-4B-Q4_K_M.gguf" \
--local-dir /path/to/your/models/unsloth/Qwen3.5-4B-GGUF/
# 3. Start infrastructure (llama-cpp + SearXNG + TEI embed + TEI rerank)
make infra-up
# 4. Start the researcher service
make up
# 5. Research something
curl -X POST http://localhost:33100/research \
-H 'Content-Type: application/json' \
-d '{"query": "What are the latest advances in fusion energy?"}'
```
Web UI with token streaming: http://localhost:33100/
### Option B — OpenAI
```bash
cp .env.example .env
# Edit .env:
# LLM_BASE_URL=https://api.openai.com/v1
# LLM_MODEL=gpt-4.1-mini
# LLM_API_KEY=sk-...
# LLM_FAST_BASE_URL= (leave empty — use same backend for all stages)
# Start infra (SearXNG only; llama-cpp is not required)
make infra-up
make up
curl -X POST http://localhost:33100/research \
-H 'Content-Type: application/json' \
-d '{"query": "Impact of quantum computing on cryptography"}'
```
### Option C — CLI (no Docker)
```bash
cargo build --release
# Run against a local llama.cpp + SearXNG
LLM_BASE_URL=http://localhost:8080/v1 \
SEARXNG_URL=http://localhost:4000 \
RUST_LOG=info \
./target/release/researcher --query "Rust async runtime internals"
# Save report to file
./target/release/researcher --query "..." --output report.md
```
### Option D — Ollama
```bash
# In .env:
LLM_BASE_URL=http://host.docker.internal:11434/v1
LLM_MODEL=qwen2.5:7b
LLM_API_KEY=ollama
LLM_FAST_BASE_URL= # empty = same model for all stages
```
### Option E — Vertex AI (ADC auth)
```bash
cp .env.example .env
# Edit .env:
# LLM_PROVIDER=vertex
# LLM_MODEL=gemini-2.5-flash
# VERTEX_PROJECT=sp-mrv-gemini-dev
# VERTEX_LOCATION=eu
# LLM_BASE_URL=https://aiplatform.googleapis.com/v1beta1
# LLM_FAST_PROVIDER=vertex # optional; empty = inherit primary provider
```
Authenticate locally:
```bash
gcloud auth application-default login
```
Then run:
```bash
make infra-up # for SearXNG / optional TEI services
make up
```
## MCP Server
`researcher-mcp` exposes the full pipeline as MCP tools over stdio. Use with Claude Desktop, Claude Code, or any MCP client.
```bash
cargo build --release --bin researcher-mcp
# → target/release/researcher-mcp (~6MB)
```
### Claude Desktop (`~/.config/claude/claude_desktop_config.json`)
```json
{
"mcpServers": {
"researcher": {
"command": "/path/to/researcher-mcp",
"env": {
"LLM_BASE_URL": "http://localhost:8080/v1",
"LLM_MODEL": "Qwen3.5-4B-Q4_K_M",
"SEARXNG_URL": "http://localhost:4000",
"STRIP_THINKING_TOKENS": "true",
"EMBED_BASE_URL": "http://localhost:30082",
"RERANK_BASE_URL": "http://localhost:30083"
}
}
}
}
```
### Claude Code (`.mcp.json`)
```json
{
"mcpServers": {
"researcher": {
"command": "/path/to/researcher-mcp",
"env": {
"LLM_BASE_URL": "http://localhost:8080/v1",
"SEARXNG_URL": "http://localhost:4000"
}
}
}
}
```
### MCP Tools
| Tool | Parameters | Description |
|------|-----------|-------------|
| `research` | `query`, `mode?`, `domain_profile?`, `domains?`, `max_queries?`, `max_sources?` | General web research → markdown report |
| `research_person` | `name`, `method?` | Meeting prep brief (career, voice, interests, hooks). `method`: professional\|personal\|both |
| `research_company` | `name`, `country?` | Company brief (what they do, size, news, culture, strategy) |
| `research_code` | `framework`, `version?`, `aspects?`, `repo?`, `query?` | Library research: bugs, changelog, community sentiment |
| `search_jobs` | `query`, `mode?` | Remote job search matched to your `profiles.toml [job-profile]`. `mode`: list\|deep |
| `market_insight` | `query`, `asset_class?`, `mode?` | Stock/crypto/macro research. `asset_class`: stock\|crypto\|macro |
**Research modes:** `quick` (snippets), `summary` (bullets), `report` (full markdown, default), `deep` (thorough)
## HTTP API
### `POST /research`
Blocking — waits for the full report.
```bash
curl -X POST http://localhost:33100/research \
-H 'Content-Type: application/json' \
-d '{
"query": "Rust async runtimes compared",
"mode": "report",
"max_queries": 4,
"max_sources": 4
}'
```
Response:
```json
{
"topic": "Rust async runtimes compared",
"queries": ["What is Tokio?", "..."],
"source_count": 14,
"report": "# Research Report\n\n..."
}
```
### `POST /research/stream`
SSE token stream — progress events then report tokens.
```bash
curl -X POST http://localhost:33100/research/stream \
-H 'Content-Type: application/json' \
-d '{"query": "history of the internet"}' \
--no-buffer
```
Events:
```
data: {"type":"progress","message":"🔍 Planning research queries..."}
data: {"type":"progress","message":"📋 Generated 4 search queries","data":{"queries":[...]}}
data: {"type":"progress","message":"🌐 Crawling 4 queries in parallel..."}
data: {"type":"token","token":"# Research Report\n\n"}
...
event: complete
data: {"type":"complete","topic":"...","report":"# Research Report\n\n..."}
```
### `GET /health`
Returns `200 ok`.
## Configuration
All settings are environment variables. Copy `.env.example` to `.env` and edit.
### LLM
| Variable | Default | Description |
|----------|---------|-------------|
| `LLM_PROVIDER` | `openai` | `openai` or `vertex` |
| `LLM_BASE_URL` | `http://localhost:8080/v1` | Any OpenAI-compatible endpoint; for Vertex use `https://aiplatform.googleapis.com/v1beta1` |
| `LLM_MODEL` | `Qwen3.5-4B-Q4_K_M` | Model name sent in requests |
| `LLM_API_KEY` | `no-key-needed` | Set to `sk-...` for OpenAI-compatible providers; ignored for Vertex |
| `LLM_MAX_TOKENS` | `4096` | Max tokens per LLM call |
| `LLM_TEMPERATURE` | `0.3` | Generation temperature |
| `STRIP_THINKING_TOKENS` | `true` | Strip `<think>...</think>` from Qwen3 responses |
| `VERTEX_PROJECT` | `` | Required when `LLM_PROVIDER=vertex` |
| `VERTEX_LOCATION` | `us-central1` | Vertex AI region |
### Dual-model routing (optional)
Route different pipeline stages to a second model backend. Leave `LLM_FAST_BASE_URL` empty to use a single backend for everything (the default and recommended setup).
| Variable | Default | Description |
|----------|---------|-------------|
| `LLM_FAST_BASE_URL` | `` (disabled) | Fast LLM endpoint; empty = use heavy backend |
| `LLM_FAST_MODEL` | `Qwen3.5-4B-Q4_K_M` | Model name for fast LLM |
| `LLM_FAST_PROVIDER` | `` | Fast provider override; empty = inherit `LLM_PROVIDER` |
| `LLM_FAST_API_KEY` | `` | Fast LLM API key; empty = inherit `LLM_API_KEY` |
| `LLM_FAST_MAX_TOKENS` | `4096` | Max tokens for fast model |
| `LLM_FAST_STAGES` | `planner,summarizer,publisher` | Pipeline stages routed to fast LLM |
Valid stage names: `planner`, `summarizer`, `publisher`
### Search & crawling
| Variable | Default | Description |
|----------|---------|-------------|
| `SEARXNG_URL` | `http://localhost:4000` | SearXNG instance URL |
| `BRAVE_API_KEY` | `` | Brave Search API key (empty = disabled; default fallback for all profiles) |
| `TAVILY_API_KEY` | `` | Tavily API key — used for `news` profile (empty = disabled) |
| `EXA_API_KEY` | `` | Exa API key — used for `academic` profile (empty = disabled) |
| `SEARCH_RESULTS_PER_QUERY` | `8` | Results fetched per sub-question |
| `MAX_SEARCH_QUERIES` | `4` | Sub-questions the planner generates |
| `MAX_SOURCES_PER_QUERY` | `4` | Pages scraped per query |
| `MAX_PAGE_CHARS` | `8000` | Max characters extracted per page |
### Embeddings & reranking (optional)
Both are disabled when their `*_BASE_URL` is empty — the pipeline skips those stages gracefully.
| Variable | Default | Description |
|----------|---------|-------------|
| `EMBED_BASE_URL` | `` (disabled) | TEI embed endpoint (e.g. `http://localhost:8081`) |
| `DEDUP_THRESHOLD` | `0.92` | Cosine similarity cutoff for deduplication |
| `RERANK_BASE_URL` | `` (disabled) | TEI rerank endpoint (e.g. `http://localhost:8082`) |
| `RERANK_RELEVANCE_WEIGHT` | `0.7` | Cross-encoder score weight |
| `RERANK_AUTHORITY_WEIGHT` | `0.2` | Domain authority weight |
| `RERANK_QUALITY_WEIGHT` | `0.1` | Content quality weight |
### Quality filter
| Variable | Default | Description |
|----------|---------|-------------|
| `MIN_CONTENT_WORDS` | `100` | Minimum word count per page |
| `MIN_TEXT_DENSITY` | `0.05` | Minimum text/HTML density ratio |
### Auth (optional — for gated sources)
| Variable | Description |
|----------|-------------|
| `LINKEDIN_COOKIE` | Cookie header for linkedin.com |
| `TWITTER_COOKIE` | Cookie header for twitter.com / x.com |
| `FB_COOKIE` | Cookie header for facebook.com |
| `INSTAGRAM_COOKIE` | Cookie header for instagram.com |
| `ADZUNA_APP_ID` | Adzuna API app ID (job search) — free at developer.adzuna.com |
| `ADZUNA_APP_KEY` | Adzuna API key |
| `ADZUNA_COUNTRY` | `us` — Adzuna country code (us, gb, de, fr, …) |
### Server
| Variable | Default | Description |
|----------|---------|-------------|
| `BIND_ADDR` | `0.0.0.0:3000` | HTTP server bind address |
| `RUST_LOG` | `info` | Log level filter |
### Tracing (Langfuse, optional)
| Variable | Default | Description |
|----------|---------|-------------|
| `LANGFUSE_HOST` | `` (disabled) | Langfuse base URL; empty disables tracing |
| `LANGFUSE_PUBLIC_KEY` | `` | Langfuse project public key |
| `LANGFUSE_SECRET_KEY` | `` | Langfuse project secret key |
| `LANGFUSE_MAX_PAYLOAD_CHARS` | `50000` | Truncate captured LLM/stage I/O |
## Domain Profiles
`profiles.toml` defines named source lists. Pass `domain_profile="tech-news"` to any tool or API call to restrict searches to those domains. Profiles can be combined with a raw `domains` list — they are unioned.
Built-in profiles:
| Profile | Sources |
|---------|---------|
| `tech-news` | Hacker News, lobste.rs, r/programming, r/rust, r/technology |
| `llm-news` | HuggingFace, arXiv, r/LocalLLaMA, r/MachineLearning |
| `academic` | arXiv, Semantic Scholar, PubMed |
| `news` | BBC, Reuters, r/worldnews, r/news, r/europe |
| `travel` | TripAdvisor, Lonely Planet, Wikivoyage, r/travel |
| `shopping-ro` | OLX.ro, eMag.ro, Altex.ro (Romanian market) |
Add custom profiles in `profiles.toml`:
```toml
[my-profile]
domains = ["example.com", "docs.example.com"]
```
## Job Search
Configure your profile in `profiles.toml` under `[job-profile]`:
```toml
[job-profile]
title = "Senior AI Engineer"
seniority = "senior"
salary_floor = "150000 USD"
remote_only = true
skills = ["Rust", "Python", "LLMs", "MLOps", "RAG"]
preferred_company_size = "startup to mid-size"
avoid_industries = ["gambling", "crypto"]
about_me = """
Brief summary of your background and what you're looking for.
"""
```
Then call `search_jobs` via MCP or HTTP. Use `mode: "deep"` for full company briefs on the top 5 matches.
## Infrastructure Stack
The project uses a two-compose layout to keep the AI infrastructure reusable across projects:
```
infra/docker-compose.yml ← always-on: SearXNG, llama-cpp, TEI embed, TEI rerank
docker-compose.yml ← researcher app only (joins ai-infra-net)
```
```bash
# Start infrastructure first
make infra-up
# Then start the researcher app
make up
# Logs
make infra-logs # infrastructure services
make logs # researcher app
# Stop everything
make stop-all
```
### Services
| Service | Port | Description |
|---------|------|-------------|
| `searxng` | 4000 | Private metasearch (Google/DDG/Bing, optionally via Tor) |
| `llama-cpp` | 30080 | Heavy LLM — NVIDIA GPU (llama.cpp CUDA image) |
| `llama-cpp-fast` | 30081 | Fast LLM — AMD GPU via ROCm, or second card |
| `tei-embed` | 8081 | `BAAI/bge-large-en-v1.5` embeddings (CPU) |
| `tei-rerank` | 8082 | `cross-encoder/ms-marco-MiniLM-L-6-v2` reranker (CPU) |
| `researcher` | 33100 | Researcher HTTP server |
The infra stack creates a shared Docker network `ai-infra-net`. Other projects can join it and reuse the LLM and search services without running their own copies.
### Single-GPU setup
Set `LLM_FAST_BASE_URL=` (empty) in `.env`. All pipeline stages use the same `llama-cpp` backend.
### AMD GPU (ROCm)
`llama-cpp-fast` uses the ROCm image and targets `/dev/kfd` + `/dev/dri`. Works with RDNA2/RDNA3 on kernel 6.x.
## Building from Source
```bash
# Prerequisites (Debian/Ubuntu)
sudo apt-get install pkg-config libssl-dev
# Type-check only (fast)
cargo check
# Build both binaries (optimized — ~30-60s with LTO)
cargo build --release
# Lint
cargo clippy -- -D warnings
```
Binaries:
- `target/release/researcher` — HTTP server + CLI
- `target/release/researcher-mcp` — MCP stdio server (~6MB)
### Docker image
```bash
docker build -t researcher .
# Multi-stage build: rust:slim builder → distroless runtime (~8MB total)
```
## LLM Backend Compatibility
| Backend | `LLM_BASE_URL` | Notes |
|---------|----------------|-------|
| **llama.cpp** | `http://localhost:8080/v1` | Recommended local; CUDA/ROCm/CPU images available |
| **Ollama** | `http://localhost:11434/v1` | Easy model management |
| **vLLM** | `http://localhost:8000/v1` | Best for multi-user / high concurrency |
| **LM Studio** | `http://localhost:1234/v1` | Desktop GUI for local models |
| **OpenAI** | `https://api.openai.com/v1` | Set `LLM_API_KEY=sk-...` |
| **Google Gemini** | `https://generativelanguage.googleapis.com/v1beta/openai/` | Set `LLM_API_KEY=AIza...`, model e.g. `gemini-2.0-flash` |
| **Vertex AI** | `https://aiplatform.googleapis.com/v1beta1` | Set `LLM_PROVIDER=vertex`, `VERTEX_PROJECT`, `VERTEX_LOCATION`; authenticate with ADC |
| **Anthropic** | Use [LiteLLM](https://github.com/BerriAI/litellm) proxy | OpenAI-compatible wrapper |
## Recommended Models
`Qwen3.5-4B-Q4_K_M` is the recommended model — it runs on ~3GB VRAM and produces excellent research reports.
| Model | VRAM | Notes |
|-------|------|-------|
| `Qwen3.5-4B-Q4_K_M` | ~3GB | Recommended — fast, high quality |
| `Qwen3.5-9B-Q4_K_M` | ~6GB | Larger, marginal gains for most queries |
| `gpt-4.1-mini` | — | Cloud alternative |
Set `STRIP_THINKING_TOKENS=true` for all Qwen3 models to strip internal `<think>` tokens from responses.
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessUnresponsive