Security Notice (May 2026): This version includes critical security updates for LangChain dependencies. Please upgrade to the latest version or ensure you have
langchain-community>=0.3.27andlangchain-text-splitters>=0.3.9installed. See SECURITY.md for details.
A plug-and-play RAG (Retrieval-Augmented Generation) chatbot for Wagtail CMS. Drop wagtail_rag into any Wagtail site to get a fully working chatbot backed by your page content — no model-specific configuration required.
- Universal: works with any Wagtail page model; auto-discovers content fields
- Per-field chunking: each field (body, introduction, …) is chunked independently with section metadata, so the LLM always knows where a chunk came from
- Hybrid retrieval: vector similarity search + optional Wagtail full-text search
- Multiple vector stores: FAISS (default), ChromaDB, pgvector (PostgreSQL)
- Multiple embedding providers: HuggingFace, Sentence Transformers, OpenAI
- Multiple LLM providers: Ollama (local), OpenAI, Anthropic
- Chat history: server-side with LLM summarisation of older turns
- Unified CLI: one management command covers indexing, chat, and pipeline smoke-testing
- CSRF protection: API endpoint enforces Django CSRF on POST requests
# Local stack (recommended for development): FAISS + Sentence Transformers + Ollama
pip install "wagtail-rag[local] @ git+https://github.com/awais786/wagtail-ai-chat.git"
# OpenAI
pip install "wagtail-rag[openai] @ git+https://github.com/awais786/wagtail-ai-chat.git"
# All providers
pip install "wagtail-rag[all] @ git+https://github.com/awais786/wagtail-ai-chat.git"
# Local checkout (development)
pip install -e ".[local]"Available extras:
| Extra | Installs |
|---|---|
faiss |
faiss-cpu |
chroma |
chromadb |
pgvector |
psycopg2-binary, sqlalchemy |
sentence-transformers |
sentence-transformers |
huggingface |
langchain-huggingface, sentence-transformers |
openai |
langchain-openai |
ollama |
ollama |
anthropic |
langchain-anthropic |
local |
faiss + sentence-transformers + ollama |
all |
every provider |
# settings.py
INSTALLED_APPS = [
# ...
"wagtail_rag",
]
wagtail_raghas no database models — no migration needed.
Pick one setup block and add it to settings.py:
# --- Option A: Local (Ollama + Sentence Transformers + FAISS) ---
WAGTAIL_RAG = {
"embedding": {"provider": "sentence-transformers", "model": "all-MiniLM-L6-v2"},
"llm": {"provider": "ollama", "model": "mistral"},
"vector_store": {"backend": "faiss", "path": os.path.join(BASE_DIR, "faiss_index")},
}
# --- Option B: OpenAI ---
WAGTAIL_RAG = {
"embedding": {"provider": "openai", "model": "text-embedding-3-small"},
"llm": {"provider": "openai", "model": "gpt-4o"},
"vector_store": {"backend": "faiss", "path": os.path.join(BASE_DIR, "faiss_index")},
}
# Set OPENAI_API_KEY as an environment variable, not hereFor Option A, make sure Ollama is running: ollama serve and ollama pull mistral.
# urls.py
from django.urls import include, path
from wagtail import urls as wagtail_urls
urlpatterns = [
path("", include("wagtail_rag.urls")), # must come before wagtail_urls
path("", include(wagtail_urls)),
]This exposes:
GET/POST /api/rag/chat/— chat APIGET /chatbox/— standalone widget page (for testing)
python manage.py rag indexpython manage.py rag chat -q "What content is on this site?"Or run the built-in smoke test:
python manage.py rag testAdd just before </body> in your base template:
{% include "wagtail_rag/chatbox.html" %}This renders a floating action button (bottom-right). Clicking it opens the chat panel. The widget reads the Django csrftoken cookie and sends it automatically on every request.
All RAG operations go through a single command: manage.py rag <subcommand>.
python manage.py rag index # full index build
python manage.py rag index --clear # wipe collection only
python manage.py rag index --page-id 42 # re-index one pageIf you change embedding models, always reset first:
python manage.py rag index --clear
python manage.py rag indexpython manage.py rag chat # interactive loop
python manage.py rag chat -q "What is sourdough?" # single question
python manage.py rag chat --search-only # retrieval only, skip LLM
python manage.py rag chat --no-sources # hide source list
python manage.py rag chat --no-history # stateless mode
python manage.py rag chat --filter '{"model":"BreadPage"}'Interactive session commands: exit/quit, clear (new session), sources on/off, Ctrl+C.
python manage.py rag test # built-in questions, full RAG
python manage.py rag test --search-only # retrieval only (faster)
python manage.py rag test --questions "Q1" "Q2" "Q3" # custom questions
python manage.py rag test --filter '{"model":"BlogPage"}' # scoped to a modelPass criteria: non-empty answer and at least 1 source retrieved (full RAG), or at least 1 source (search-only).
Override the default questions via settings:
WAGTAIL_RAG_TEST_QUESTIONS = [
"What breads do you sell?",
"Where are you located?",
]make install-local # pip install -e ".[local,test,dev]"
make install-openai # pip install -e ".[openai,test,dev]"
make install-all # pip install -e ".[all,test,dev]"
make index # rag index
make index-reset # rag index --clear
make index-rebuild # reset then index
make chat # rag chat
make test-rag # rag test (full pipeline)
make test-rag-search # rag test --search-only
make test # pytest unit tests
make test-cov # pytest with coverage
make lint # black + flake8
make format # black auto-format
make clean # remove cache and build filesAll configuration lives under a single WAGTAIL_RAG dict in settings.py. Flat WAGTAIL_RAG_* keys are still accepted as fallbacks for backwards compatibility.
import os
WAGTAIL_RAG = {
# ── Embeddings ────────────────────────────────────────────────────────
"embedding": {
"provider": "sentence-transformers", # "sentence-transformers" | "huggingface" | "openai"
"model": "all-MiniLM-L6-v2",
},
# ── LLM ───────────────────────────────────────────────────────────────
"llm": {
"provider": "ollama", # "ollama" | "openai" | "anthropic"
"model": "mistral",
"max_context_chars": 0, # max chars of context passed to LLM; 0 = unlimited
"enable_history": True, # server-side per-session chat history
"history_recent_messages": 6, # recent turns kept verbatim (older turns summarised)
},
# ── Vector store ──────────────────────────────────────────────────────
"vector_store": {
"backend": "faiss", # "faiss" | "chroma" | "pgvector"
"path": os.path.join(BASE_DIR, "faiss_index"), # directory for FAISS / ChromaDB files
"collection": "wagtail_rag", # collection / index name
# "connection_string": "postgresql+psycopg2://..." # pgvector only; omit to auto-derive
},
# ── Indexing ──────────────────────────────────────────────────────────
"indexing": {
"chunk_size": 1500, # characters per chunk
"chunk_overlap": 100, # overlap between consecutive chunks
"batch_size": 100, # pages embedded per batch
"skip_if_indexed": True, # skip pages that haven't changed since last index
"prune_deleted": True, # remove chunks for pages deleted from Wagtail
# key = "app.ModelName"
# value = "*" (auto-discover all content fields) or ["field1", "field2"] (explicit)
"models": {
"locations.LocationPage": ["introduction", "body", "address"],
"breads.BreadPage": "*",
"blog.BlogPage": "*",
},
},
# ── Search / retrieval ────────────────────────────────────────────────
"search": {
"k": 8, # chunks retrieved per query
"max_sources": 3, # unique pages shown as sources in the response
"use_hybrid": True, # combine vector search + Wagtail full-text search
"use_query_expansion": False, # generate multiple query variants via MultiQueryRetriever
},
# ── API ───────────────────────────────────────────────────────────────
"api": {
"max_question_length": 150, # max characters in a question; 0 = unlimited
"max_request_body_size": 1048576, # max POST body size in bytes (default 1 MB)
"rate_limit_per_minute": 0, # requests per IP per minute; 0 = disabled
},
}| Group | Key | Default | Description |
|---|---|---|---|
embedding |
provider |
"huggingface" |
Embedding backend |
embedding |
model |
provider default | Model name passed to the provider |
llm |
provider |
"ollama" |
LLM backend |
llm |
model |
provider default | Model name |
llm |
max_context_chars |
0 |
Truncate retrieved context; 0 = no limit |
llm |
enable_history |
True |
Enable server-side chat history |
llm |
history_recent_messages |
6 |
Recent turns kept verbatim |
vector_store |
backend |
"faiss" |
Storage backend |
vector_store |
path |
BASE_DIR/faiss_index |
Directory for FAISS / ChromaDB |
vector_store |
collection |
"wagtail_rag" |
Collection / index name |
vector_store |
connection_string |
derived from DATABASES |
pgvector only |
indexing |
models |
{} |
Models and fields to index |
indexing |
chunk_size |
1500 |
Characters per chunk |
indexing |
chunk_overlap |
100 |
Overlap between chunks |
indexing |
skip_if_indexed |
True |
Skip unchanged pages |
indexing |
prune_deleted |
True |
Remove stale chunks |
search |
k |
8 |
Chunks retrieved per query |
search |
max_sources |
3 |
Source pages shown in response |
search |
use_hybrid |
True |
Vector + Wagtail full-text search |
search |
use_query_expansion |
True |
MultiQueryRetriever query expansion |
api |
max_question_length |
150 |
Max question length; 0 = no limit |
api |
max_request_body_size |
1048576 |
Max POST body (bytes) |
api |
rate_limit_per_minute |
0 |
Per-IP rate limit; 0 = disabled |
The following flat settings are still read when the corresponding grouped key is absent:
# Kept for existing deployments — prefer the grouped dict above
WAGTAIL_RAG_EXCLUDE_MODELS = ["wagtailcore.Page", "wagtailcore.Site"]
WAGTAIL_RAG_MODELS = ["breads.BreadPage"] # fallback for indexing.models
WAGTAIL_RAG_TEST_QUESTIONS = ["What breads do you sell?"] # rag test questionsUse pgvector when you want the vector index stored in your existing PostgreSQL database rather than on disk.
WAGTAIL_RAG = {
"embedding": {
"provider": "openai",
"model": "text-embedding-3-small",
},
"llm": {
"provider": "openai",
"model": "gpt-4o",
},
"vector_store": {
"backend": "pgvector",
"collection": "wagtail_rag",
# Explicit connection string — omit to auto-derive from DATABASES['default']
"connection_string": "postgresql+psycopg2://user:password@localhost:5432/mydb",
},
}If connection_string is omitted, the connection is derived automatically from DATABASES['default'] (must be a PostgreSQL engine). Install the extra:
pip install "wagtail-rag[pgvector]"No CSRF token required for GET. The response also sets the csrftoken cookie for subsequent POST requests.
curl "http://localhost:8000/api/rag/chat/?q=What+types+of+bread+do+you+have?"CSRF token required. Obtain it from the csrftoken cookie set by the GET above:
# Step 1: get the CSRF token
CSRF=$(curl -sc /tmp/jar "http://localhost:8000/api/rag/chat/?q=ping" \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('answer',''))" 2>/dev/null; \
grep csrftoken /tmp/jar | awk '{print $NF}')
# Step 2: POST with the token
curl -b /tmp/jar -X POST http://localhost:8000/api/rag/chat/ \
-H "Content-Type: application/json" \
-H "X-CSRFToken: $CSRF" \
-d '{"question": "What types of bread do you have?", "session_id": "abc123"}'Response 200:
{
"answer": "We have several types including...",
"sources": [
{
"content": "...",
"metadata": {"title": "Multigrain Bread", "url": "/breads/multigrain/", "model": "BreadPage", "section": "body"}
}
],
"session_id": "abc123"
}Error codes: 400 bad request · 403 missing/invalid CSRF token · 413 body too large · 415 wrong Content-Type · 500 server error.
from wagtail_rag.chatbot import get_chatbot
chatbot = get_chatbot()
result = chatbot.query("What types of bread do you have?")
print(result["answer"])
print(result["sources"])
# Filter by model
chatbot = get_chatbot(metadata_filter={"model": "BreadPage"})
# Search only (no LLM call)
result = chatbot.query("sourdough", search_only=True)
# Specific provider
chatbot = get_chatbot(llm_provider="openai", model_name="gpt-4o")-
Indexing (
rag index): discovers live Wagtail pages → extracts each field independently → chunks with paragraph preservation → prependsPage: / Section:header to every chunk → upserts into vector store with deterministic IDs ({page_id}_{field}_{chunk_index}). Stale chunks are removed before re-indexing. -
Querying (
rag chat/ API): embeds the question → vector similarity search → optional Wagtail full-text search → deduplicate & title-boost → pass top-k chunks as context to LLM → return answer + sources.
# Unit tests
pytest wagtail_rag/tests/ -v
# With coverage
pytest wagtail_rag/tests/ --cov=wagtail_rag --cov-report=term-missing
# Individual modules
pytest wagtail_rag/tests/test_rag_command.py # unified rag command
pytest wagtail_rag/tests/test_providers.py # embedding & LLM factories
pytest wagtail_rag/tests/test_extraction.py # chunking & field extraction
pytest wagtail_rag/tests/test_index_builder.py # pgvector, batch upsert helpers
pytest wagtail_rag/tests/test_generation.py # LLM generation
pytest wagtail_rag/tests/test_api_views.py # REST API + CSRF
pytest wagtail_rag/tests/test_search.py # hybrid searchCI runs on Python 3.11 and 3.12 against Django 4.2 and 5.2.
"Collection expecting embedding with dimension of X, got Y" — changed embedding model without resetting the index.
make index-rebuild"The model X does not exist" — model name doesn't match the provider. Set the correct model in WAGTAIL_RAG:
WAGTAIL_RAG = {
"llm": {"provider": "openai", "model": "gpt-4o"},
...
}"No pages found to index" — check pages are published and indexing.models keys use the correct format ("app.ModelName").
"Connection refused" (Ollama) — run ollama serve first, then ollama pull mistral.
403 on POST — CSRF token missing. Read the csrftoken cookie from a GET response and send it as X-CSRFToken header.
Import errors — install the required extra: pip install "wagtail-rag[local]".
- Python 3.9+
- Django 4.2+
- Wagtail 6.0+
- LangChain (installed automatically)
- At least one provider extra (see Installation)
MIT — see LICENSE.