Initial commit: SearXNG + Firecrawl + HHEM API stack

Self-hosted Docker Compose stack for DGX Spark (ARM64/aarch64, CPU-only):
- SearXNG on port 8889 with JSON API enabled, rate limiting off
- Firecrawl (built from local source) on port 3002, no API key required
- HHEM API (FastAPI + vectara/hallucination_evaluation_model) on port 8881
- Portainer stack YAML using dgx_net external network
- build-images.sh to pre-build local images before Portainer deploy
- HF model cache bind-mounted from /home/sparky/LLMs/huggingface

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-29 22:29:28 +02:00
commit dd44a2c2b1
8 changed files with 794 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
# Docker build artifacts
*.log
# Don't commit secrets or local overrides
.env
.env.local

149
Portainer-Stack.yaml Normal file
View File

@ -0,0 +1,149 @@
services:
# ===========================================================================
# 1. SearXNG — meta-search with JSON API
# ===========================================================================
searxng:
image: docker.io/searxng/searxng:latest
container_name: searxng-8889
restart: unless-stopped
ports:
- "8889:8080"
volumes:
- /home/sparky/Docker/searXNG Firecrawl HHEM /searxng:/etc/searxng:rw
- searxng-cache:/var/cache/searxng:rw
environment:
- SEARXNG_BASE_URL=http://localhost:8889/
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
networks:
- dgx_net
# ===========================================================================
# 2. Firecrawl — web scraper (self-hosted, no API key required)
# Images must be pre-built: run build-images.sh before deploying this stack
# ===========================================================================
firecrawl-redis:
image: redis:7-alpine
container_name: firecrawl-redis
restart: unless-stopped
command: redis-server --bind 0.0.0.0 --save 60 1 --loglevel warning
volumes:
- firecrawl-redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
networks:
- dgx_net
firecrawl-postgres:
image: firecrawl-postgres:local
container_name: firecrawl-postgres
restart: unless-stopped
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=postgres
volumes:
- firecrawl-pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres"]
interval: 10s
timeout: 5s
retries: 5
networks:
- dgx_net
firecrawl-playwright:
image: firecrawl-playwright:local
container_name: firecrawl-playwright
restart: unless-stopped
environment:
- PORT=3000
shm_size: "2gb"
networks:
- dgx_net
firecrawl-api:
image: firecrawl-api:local
container_name: firecrawl-api-3002
restart: unless-stopped
ports:
- "3002:3002"
environment:
- USE_DB_AUTHENTICATION=false
- REDIS_URL=redis://firecrawl-redis:6379
- REDIS_RATE_LIMIT_URL=redis://firecrawl-redis:6379
- PLAYWRIGHT_MICROSERVICE_URL=http://firecrawl-playwright:3000/scrape
- NUQ_DATABASE_URL=postgres://postgres:postgres@firecrawl-postgres:5432/postgres
- HOST=0.0.0.0
- PORT=3002
- NODE_ENV=production
- BULL_AUTH_KEY=aitools-bull-changeme
- SEARXNG_ENDPOINT=http://searxng:8080
command: node dist/src/harness.js --start-docker
extra_hosts:
- "host.docker.internal:host-gateway"
ulimits:
nofile:
soft: 65535
hard: 65535
depends_on:
- firecrawl-redis
- firecrawl-postgres
- firecrawl-playwright
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3002/health || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
networks:
- dgx_net
# ===========================================================================
# 3. HHEM API — Vectara hallucination evaluator (CPU-only, ARM64)
# Images must be pre-built: run build-images.sh before deploying this stack
# ===========================================================================
hhem-api:
image: hhem-api:local
container_name: hhem-api-8881
restart: unless-stopped
ports:
- "8881:8881"
volumes:
# Bind-mount the shared HF model cache so the HHEM model is only
# downloaded once and lives alongside all other LLMs on this host.
- /home/sparky/LLMs/huggingface:/hf-cache
environment:
- HF_HOME=/hf-cache
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8881/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 120s
networks:
- dgx_net
volumes:
searxng-cache:
firecrawl-redis-data:
firecrawl-pg-data:
networks:
dgx_net:
external: true

241
README.md Normal file
View File

@ -0,0 +1,241 @@
# AI Tools Stack — SearXNG + Firecrawl + HHEM
Self-hosted Docker Compose stack for the DGX Spark (ARM64/aarch64, CPU-only).
Runs alongside the existing n8n automation stack and exposes three services for agent use.
## Quick Start
```bash
cd ~/Docker/searXNG\ Firecrawl\ HHEM\ \
# First run: build Firecrawl from source (takes ~510 min)
docker compose build
# Start everything
docker compose up -d
# Check status
docker compose ps
```
## Services & Ports
| Service | Port | Container name | Purpose |
|------------|------|-------------------|---------|
| SearXNG | 8888 | `searxng` | Meta-search (JSON API) |
| Firecrawl | 3002 | `firecrawl-api` | Web scraper / crawler |
| HHEM API | 8881 | `hhem-api` | Hallucination evaluator |
All services share the `ai-tools` Docker network.
n8n reaches them at `http://host.docker.internal:<port>` (or `http://<DGX-IP>:<port>`).
---
## 1. SearXNG — Meta-Search
**Base URL:** `http://localhost:8888`
### Search (JSON)
```
GET /search?q=<query>&format=json&categories=general
```
**Example curl:**
```bash
curl "http://localhost:8888/search?q=Claude+AI&format=json" | jq '.results[0]'
```
**Response shape:**
```json
{
"query": "Claude AI",
"results": [
{
"title": "...",
"url": "https://...",
"content": "...",
"engine": "google",
"score": 1.0
}
]
}
```
### n8n HTTP Request node
| Field | Value |
|-------|-------|
| Method | GET |
| URL | `http://host.docker.internal:8888/search` |
| Query params | `q` = `{{ $json.query }}`, `format` = `json`, `categories` = `general` |
| Response | JSON |
---
## 2. Firecrawl — Web Scraper
**Base URL:** `http://localhost:3002`
**No API key required** (self-hosted, `USE_DB_AUTHENTICATION=false`).
### Scrape a single URL
```
POST /v1/scrape
Content-Type: application/json
{
"url": "https://example.com",
"formats": ["markdown"]
}
```
**Example curl:**
```bash
curl -X POST http://localhost:3002/v1/scrape \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "formats": ["markdown"]}'
```
**Response shape:**
```json
{
"success": true,
"data": {
"markdown": "# Example Domain\n\n...",
"metadata": {
"title": "Example Domain",
"sourceURL": "https://example.com"
}
}
}
```
### Crawl a site (async)
```
POST /v1/crawl
Content-Type: application/json
{
"url": "https://example.com",
"limit": 10,
"scrapeOptions": { "formats": ["markdown"] }
}
```
Returns a `jobId`. Poll `GET /v1/crawl/<jobId>` for results.
### n8n HTTP Request node (scrape)
| Field | Value |
|-------|-------|
| Method | POST |
| URL | `http://host.docker.internal:3002/v1/scrape` |
| Body (JSON) | `{"url": "{{ $json.url }}", "formats": ["markdown"]}` |
| Response | JSON |
### Queue admin UI
`http://localhost:3002/admin/aitools-bull-changeme/queues`
---
## 3. HHEM API — Hallucination Evaluator
**Base URL:** `http://localhost:8881`
**Model:** `vectara/hallucination_evaluation_model` (183 M BERT-based, CPU-only)
> On first start the model is downloaded from HuggingFace (~700 MB).
> Cached in the `hhem-model-cache` volume for subsequent restarts.
### Score
```
POST /score
Content-Type: application/json
{
"source": "The Eiffel Tower is in Paris, France.",
"generated": "The Eiffel Tower is located in Paris."
}
```
**Response:**
```json
{
"score": 0.9741,
"label": "grounded"
}
```
| Score range | Meaning |
|-------------|---------|
| > 0.5 | **Grounded** — generated text is faithful to source |
| ≤ 0.5 | **Hallucinated** — generated text contradicts or invents facts |
**Health check:**
```bash
curl http://localhost:8881/health
# {"status":"ok","model":"vectara/hallucination_evaluation_model","loaded":true}
```
### n8n HTTP Request node
| Field | Value |
|-------|-------|
| Method | POST |
| URL | `http://host.docker.internal:8881/score` |
| Body (JSON) | `{"source": "{{ $json.scrapedContent }}", "generated": "{{ $json.llmAnswer }}"}` |
| Response | JSON |
---
## n8n Agentic Pipeline Example
This stack is designed for a **Search → Scrape → Validate** pipeline:
```
1. [HTTP Request] → SearXNG: search for the user's query, extract top URL
2. [HTTP Request] → Firecrawl /v1/scrape: scrape the top result into markdown
3. [LLM Node] → Generate an answer grounded in the scraped content
4. [HTTP Request] → HHEM /score: validate the answer against the scraped source
5. [If] → Route: score > 0.5 → return answer | score ≤ 0.5 → flag/retry
```
### n8n expression for step 4 body:
```json
{
"source": "{{ $('Firecrawl Scrape').item.json.data.markdown }}",
"generated": "{{ $('LLM').item.json.text }}"
}
```
---
## Build Notes (ARM64 / aarch64)
- **SearXNG**: official image is multi-arch (ARM64 supported natively).
- **Redis**: `redis:7-alpine` is multi-arch.
- **Firecrawl**: built from source at `/home/sparky/Docker/firecrawl/git/firecrawl/`.
Dockerfile targets Node 22 slim which supports ARM64.
- **HHEM API**: `python:3.11-slim` + PyTorch CPU. PyPI provides `linux_aarch64` wheels for torch 2.x.
## Rebuilding after Firecrawl updates
```bash
git -C ~/Docker/firecrawl/git/firecrawl pull
docker compose build firecrawl-playwright firecrawl-api
docker compose up -d firecrawl-playwright firecrawl-api
```
## Connecting n8n to this stack
If n8n does not already have `host.docker.internal` available, add to the n8n service in its compose file:
```yaml
extra_hosts:
- "host.docker.internal:host-gateway"
```
Then use `http://host.docker.internal:<port>` in all n8n HTTP Request nodes.

23
build-images.sh Executable file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Run this ONCE before deploying the Portainer stack.
# Builds all custom images and tags them for use without a registry.
set -euo pipefail
FIRECRAWL_SRC="/home/sparky/Docker/firecrawl/git/firecrawl"
HHEM_SRC="/home/sparky/Docker/searXNG Firecrawl HHEM /hhem-api"
echo "=== [1/4] Building firecrawl-postgres:local ==="
docker build -t firecrawl-postgres:local "$FIRECRAWL_SRC/apps/nuq-postgres"
echo "=== [2/4] Building firecrawl-playwright:local ==="
docker build -t firecrawl-playwright:local "$FIRECRAWL_SRC/apps/playwright-service-ts"
echo "=== [3/4] Building firecrawl-api:local ==="
docker build -t firecrawl-api:local "$FIRECRAWL_SRC/apps/api"
echo "=== [4/4] Building hhem-api:local ==="
docker build -t hhem-api:local "$HHEM_SRC"
echo ""
echo "All images built. You can now deploy the Portainer stack."
docker images | grep -E "firecrawl|hhem-api"

178
docker-compose.yml Normal file
View File

@ -0,0 +1,178 @@
name: ai-tools-stack
# ---------------------------------------------------------------------------
# Shared build env for Firecrawl services (all built from local source)
# ---------------------------------------------------------------------------
x-firecrawl-build: &firecrawl-build
build:
context: /home/sparky/Docker/firecrawl/git/firecrawl/apps/api
dockerfile: Dockerfile
x-firecrawl-env: &firecrawl-env
USE_DB_AUTHENTICATION: "false"
REDIS_URL: redis://firecrawl-redis:6379
REDIS_RATE_LIMIT_URL: redis://firecrawl-redis:6379
PLAYWRIGHT_MICROSERVICE_URL: http://firecrawl-playwright:3000/scrape
NUQ_DATABASE_URL: postgres://postgres:postgres@firecrawl-postgres:5432/postgres
BULL_AUTH_KEY: "aitools-bull-changeme"
HOST: "0.0.0.0"
PORT: "3002"
NODE_ENV: production
# Wire SearXNG so Firecrawl's /search uses it instead of direct Google
SEARXNG_ENDPOINT: http://searxng:8080
networks:
ai-tools:
name: ai-tools
driver: bridge
volumes:
searxng-cache:
firecrawl-redis-data:
firecrawl-pg-data:
hhem-model-cache:
services:
# =========================================================================
# 1. SearXNG — meta-search engine with JSON API
# =========================================================================
searxng:
image: docker.io/searxng/searxng:latest
container_name: searxng
restart: unless-stopped
ports:
- "8888:8080"
volumes:
- ./searxng:/etc/searxng:rw
- searxng-cache:/var/cache/searxng:rw
environment:
- SEARXNG_BASE_URL=http://localhost:8888/
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
networks:
- ai-tools
# =========================================================================
# 2. Firecrawl — web scraper (self-hosted, no external API key)
# Built from local source at /home/sparky/Docker/firecrawl/git/firecrawl/
# =========================================================================
firecrawl-redis:
image: redis:7-alpine
container_name: firecrawl-redis
restart: unless-stopped
command: redis-server --bind 0.0.0.0 --save 60 1 --loglevel warning
volumes:
- firecrawl-redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
networks:
- ai-tools
firecrawl-postgres:
build:
context: /home/sparky/Docker/firecrawl/git/firecrawl/apps/nuq-postgres
dockerfile: Dockerfile
container_name: firecrawl-postgres
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
volumes:
- firecrawl-pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres"]
interval: 10s
timeout: 5s
retries: 5
networks:
- ai-tools
firecrawl-playwright:
build:
context: /home/sparky/Docker/firecrawl/git/firecrawl/apps/playwright-service-ts
dockerfile: Dockerfile
container_name: firecrawl-playwright
restart: unless-stopped
environment:
PORT: 3000
shm_size: "2gb"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
networks:
- ai-tools
firecrawl-api:
<<: *firecrawl-build
container_name: firecrawl-api
restart: unless-stopped
ports:
- "3002:3002"
environment:
<<: *firecrawl-env
command: node dist/src/harness.js --start-docker
extra_hosts:
- "host.docker.internal:host-gateway"
ulimits:
nofile:
soft: 65535
hard: 65535
depends_on:
firecrawl-redis:
condition: service_healthy
firecrawl-postgres:
condition: service_healthy
firecrawl-playwright:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3002/health || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
networks:
- ai-tools
# =========================================================================
# 3. HHEM API — hallucination evaluator (Vectara, CPU-only)
# =========================================================================
hhem-api:
build:
context: ./hhem-api
dockerfile: Dockerfile
container_name: hhem-api
restart: unless-stopped
ports:
- "8881:8881"
volumes:
# Cache downloaded model weights across restarts
- hhem-model-cache:/root/.cache/huggingface
environment:
- HF_HOME=/root/.cache/huggingface
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8881/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 120s # model download on first start
networks:
- ai-tools

28
hhem-api/Dockerfile Normal file
View File

@ -0,0 +1,28 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
&& rm -rf /var/lib/apt/lists/*
# Install PyTorch CPU-only.
# The PyTorch whl/cpu index only has x86 wheels; on aarch64 we fall back to
# regular PyPI which publishes native aarch64 wheels for torch 2.x.
RUN pip install --no-cache-dir \
--extra-index-url https://download.pytorch.org/whl/cpu \
torch
RUN pip install --no-cache-dir \
transformers==4.44.2 \
fastapi==0.115.0 \
"uvicorn[standard]==0.30.6" \
pydantic==2.9.2 \
sentencepiece \
accelerate
COPY app.py .
EXPOSE 8881
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8881", "--workers", "1"]

76
hhem-api/app.py Normal file
View File

@ -0,0 +1,76 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
MODEL_ID = "vectara/hallucination_evaluation_model"
tokenizer = None
model = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global tokenizer, model
logger.info(f"Loading model {MODEL_ID} ...")
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
model = AutoModelForSequenceClassification.from_pretrained(MODEL_ID)
model.eval()
logger.info("Model ready.")
yield
del model
del tokenizer
app = FastAPI(title="HHEM API", version="1.0.0", lifespan=lifespan)
class ScoreRequest(BaseModel):
source: str
generated: str
class ScoreResponse(BaseModel):
score: float
label: str
@app.get("/health")
def health():
return {"status": "ok", "model": MODEL_ID, "loaded": model is not None}
@app.post("/score", response_model=ScoreResponse)
def score(req: ScoreRequest):
if model is None or tokenizer is None:
raise HTTPException(status_code=503, detail="Model not loaded yet")
inputs = tokenizer(
req.source,
req.generated,
return_tensors="pt",
truncation=True,
max_length=512,
padding=True,
)
with torch.no_grad():
logits = model(**inputs).logits
probs = torch.softmax(logits, dim=-1)
# Resolve which class index maps to "Factually Consistent"
id2label = model.config.id2label # e.g. {0: "Hallucinated", 1: "Factually Consistent"}
factual_idx = next(
(idx for idx, name in id2label.items() if "consistent" in name.lower() or "factual" in name.lower()),
1, # fallback: assume class 1 is the positive (consistent) class
)
factual_score = probs[0][factual_idx].item()
label = "grounded" if factual_score > 0.5 else "hallucinated"
return ScoreResponse(score=round(factual_score, 4), label=label)

93
searxng/settings.yml Normal file
View File

@ -0,0 +1,93 @@
general:
debug: false
instance_name: "SearXNG (local)"
enable_metrics: false
search:
safe_search: 0
autocomplete: ""
default_lang: "auto"
ban_time_on_fail: 5
max_ban_time_on_fail: 120
# JSON format is required for agent/API access
formats:
- html
- json
server:
port: 8080
bind_address: "0.0.0.0"
base_url: false
# Disable rate limiter so local agents (n8n, Firecrawl) can query freely
limiter: false
public_instance: false
# Change this to any random 32+ character string
secret_key: "n8n-ai-tools-searxng-secret-key-dgx-spark-local-2024"
image_proxy: false
ui:
static_use_hash: true
default_locale: ""
query_in_title: false
infinite_scroll: false
center_alignment: false
default_theme: simple
theme_args:
simple_style: auto
# Outgoing HTTP settings — no proxy needed on local network
outgoing:
request_timeout: 15.0
max_request_timeout: 30.0
useragent_suffix: ""
pool_connections: 100
pool_maxsize: 20
enable_http2: true
# Enabled engines — good balance of quality and reliability
engines:
- name: bing
engine: bing
shortcut: b
disabled: false
- name: duckduckgo
engine: duckduckgo
shortcut: d
disabled: false
- name: google
engine: google
shortcut: g
disabled: false
- name: brave
engine: brave
shortcut: br
disabled: false
- name: wikipedia
engine: wikipedia
shortcut: wp
disabled: false
- name: github
engine: github
shortcut: gh
disabled: true
- name: arxiv
engine: arxiv
shortcut: ax
disabled: true
categories_as_tabs:
general:
engines:
- bing
- duckduckgo
- google
- brave
language: all
safe_search: 0