Search, chat and research over parliamentary speeches and documents
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

10 KiB

Setting up plenum

Written to be followed step by step, by a person or an assistant. Every step has a command that proves it worked. Do not proceed past a failing check — later failures will be confusing and unrelated-looking.

If you are an assistant: run the verification command after each step and report its actual output. Do not infer success from a command exiting 0.


0. What plenum needs from you

Four external things. They are independent — you can swap any one without touching the others.

# Thing Why Can you skip it?
1 PostgreSQL 14+ with pgvector Stores everything; does full-text and vector search No
2 A chat model endpoint Powers chat and deep research Search works without it; chat does not
3 An embeddings endpoint Turns text into vectors for semantic search Full-text search works without it; vector_search does not
4 Source data A parliament's open data No

2 and 3 are separate and usually different servers. A common mistake is pointing both at one URL and wondering why embedding fails.


1. PostgreSQL

Needs the vector extension (pgvector) and pg_trgm.

docker run -d --name plenum-pg \
  -e POSTGRES_USER=plenum -e POSTGRES_PASSWORD=changeme -e POSTGRES_DB=plenum \
  -p 5432:5432 pgvector/pgvector:pg16

Or on an existing server: CREATE EXTENSION vector; CREATE EXTENSION pg_trgm;

Set the text-search dictionary for your language. This is read by database triggers, so it must be set on the database itself, not just in config:

psql -h localhost -U plenum -d plenum -c \
  "ALTER DATABASE plenum SET app.fts_config = 'swedish';"

Pick from SELECT cfgname FROM pg_ts_config;. Postgres ships stemmers for about two dozen languages. If yours is absent, use simple — search still works but without stemming, so kärnkraften will not match a search for kärnkraft.

Apply the schema:

psql -h localhost -U plenum -d plenum -f _postgres/schema.sql

Verify:

psql -h localhost -U plenum -d plenum -c \
  "SELECT count(*) AS tables FROM information_schema.tables WHERE table_schema='public';
   SELECT current_setting('app.fts_config') AS fts;"

Expect 22 tables and your chosen dictionary. If fts errors with "unrecognized configuration parameter", the ALTER DATABASE did not run or you reconnected before it took effect — it applies to new sessions only.


2. The chat model

plenum talks to anything speaking the OpenAI chat-completions protocol. It never uses a provider's native API.

Choosing

Situation Use Notes
You have a GPU vLLM Fastest. Handles concurrent requests properly.
No GPU, want it working today Ollama Easiest. Slow for deep research, fine for chat.
You want no infrastructure OpenRouter One key, many models.
Data must stay in the EU Berget AI Swedish provider, EU-hosted.
You already have OpenAI OpenAI Works; reasoning models are handled specially.

The model needs tool calling. Without it, chat cannot search and will make things up — which for this project is the one unacceptable failure. Verified working: Qwen3 (8B and up), GPT-4o and later, Claude, Llama 3.3, Mistral Large.

Below ~7B parameters, tool calling gets unreliable. If chat answers without citing sources, suspect the model before the code.

Setting it up

vLLM

vllm serve Qwen/Qwen3-8B --port 8000

In .env:

LLM_DIRECT_URL=http://localhost:8000/v1
LLM_MODEL_SMART=Qwen/Qwen3-8B
LLM_MODEL_FAST=Qwen/Qwen3-8B
LLM_BEARER=

Ollama

ollama serve
ollama pull qwen3:8b

In .env:

LLM_DIRECT_URL=http://localhost:11434/v1
LLM_MODEL_SMART=qwen3:8b
LLM_MODEL_FAST=qwen3:4b
LLM_BEARER=

The /v1 matters. Ollama's native API is on /api and is not OpenAI-compatible.

A hosted provider as the server default

LLM_DIRECT_URL=https://openrouter.ai/api/v1
LLM_BEARER=sk-or-v1-...
LLM_MODEL_SMART=anthropic/claude-sonnet-4
LLM_MODEL_FAST=anthropic/claude-haiku-4

LLM_BEARER is the server's own key. Everyone using your site spends it — only do this behind authentication.

Letting users bring their own key

Copy providers.template.yaml to providers.yaml and keep the providers you want. Anything with user_api_key: true appears in the model picker and prompts the user for a key. That key is held for the request only, and is never written to the database or logs; the sole persisted copy is encrypted in the user's own browser.

Verify — this calls the model for real:

.venv/bin/python -c "
from dotenv import load_dotenv; load_dotenv()
import os
from packages.llm import LLM
llm = LLM(base_url=os.getenv('LLM_DIRECT_URL'), model=os.getenv('LLM_MODEL_SMART'),
          api_key=os.getenv('LLM_BEARER') or None, silent=True)
r = llm.generate(messages=[{'role':'user','content':'Reply with the word OK.'}], think=False)
print('FAILED:', r) if isinstance(r, str) else print('OK:', r.content[:60])
"

A string starting LLM request failed: means it did not work — the message names the cause. OK: followed by text means the endpoint, model name and key are all correct.

Verify tool calling separately — a model can chat fine and still not call tools:

.venv/bin/python -c "
from dotenv import load_dotenv; load_dotenv()
import os
from packages.llm import LLM, register_tool, get_tools

@register_tool
def get_population(country: str) -> str:
    '''Return a country's population.

    Args:
        country: Country name.
    '''
    return f'{country}: 10.4 million'

llm = LLM(base_url=os.getenv('LLM_DIRECT_URL'), model=os.getenv('LLM_MODEL_SMART'),
          api_key=os.getenv('LLM_BEARER') or None, tools=get_tools(['get_population']), silent=True)
llm.generate(messages=[{'role':'user','content':'Use the tool: population of Sweden?'}], think=False)
calls = [m for m in llm.messages if m.get('role') == 'tool']
print('TOOL CALLING WORKS:', calls) if calls else print('NO TOOL CALL — pick a different model')
"

3. Embeddings

Separate from chat, and configured separately. Also OpenAI-compatible.

EMBEDDING_BASE_URL=http://localhost:8003/v1
EMBEDDING_API_KEY=
LLM_MODEL_EMBEDDING=qwen3-embedding

The dimension must match the database. parliament.yaml declares embeddings.dimension: 384, and the vector(N) columns are that width. Change the model and you must change both, then re-embed the entire corpus — there is no converting existing vectors.

plenum checks this at startup and refuses to run on a mismatch, because the failure otherwise surfaces deep inside pgvector with a message that never mentions configuration.

Options:

Approach Command
vLLM vllm serve Qwen/Qwen3-Embedding-0.6B --port 8003
Ollama ollama pull nomic-embed-textEMBEDDING_BASE_URL=http://localhost:11434/v1, dimension 768
OpenAI EMBEDDING_BASE_URL=https://api.openai.com/v1, model text-embedding-3-small, dimension 1536

If you change the dimension, edit parliament.yaml before creating the schema — schema.sql reads it. On an existing database you must alter every vector(N) column and re-embed.

Verify:

.venv/bin/python -c "
from dotenv import load_dotenv; load_dotenv()
from postgres_client import pg
from parliament import PARLIAMENT
v = pg.make_embeddings(['test'])[0]
print(f'got {len(v)} dims, config expects {PARLIAMENT.embeddings.dimension}')
print('MATCH' if len(v) == PARLIAMENT.embeddings.dimension else 'MISMATCH — fix before ingesting')
"

4. Source data

For Sweden, everything is already configured:

.venv/bin/python -m ingest.cli fetch --source documents --range 2022-2025
.venv/bin/python -m ingest.cli load  --source documents
.venv/bin/python scripts/make_embeddings.py documents

Start with one range. A full corpus is tens of GB and takes hours.

For another parliament, see PORTING.md — you write a config file and an adapter.

Verify:

psql -h localhost -U plenum -d plenum -c "
SELECT count(*) AS documents, count(search_vector) AS searchable FROM documents;"

Both numbers should be equal and non-zero. If searchable is 0, the search-vector trigger did not fire — check app.fts_config from step 1.


5. Run it

.venv/bin/uvicorn backend.app:app --port 8000    # API
cd frontend && npm install && npm run dev        # UI on :5173

Verify the whole stack:

curl -s localhost:8000/api/meta | head -c 200
curl -s -X POST localhost:8000/api/search -H 'Content-Type: application/json' \
  -d '{"q":"test","limit":3}' | head -c 200

When something fails

Symptom Cause Fix
LLM request failed: ... unreachable Wrong LLM_DIRECT_URL, or the server is down curl $LLM_DIRECT_URL/models
LLM request failed: ... 401 Missing or wrong LLM_BEARER Check the key; self-hosted usually needs none
Chat answers but never cites sources Model cannot call tools Run the tool-calling check in §2
column X does not exist Schema older than the code Apply _postgres/migrations/*.sql in order
Search returns nothing, no error app.fts_config wrong or unset SELECT current_setting('app.fts_config'); then §1
expected N dimensions, got M Embedding model changed Match parliament.yaml to the model, re-embed
Startup: app.fts_config is 'x' but parliament.yaml declares 'y' The two disagree Make them match; the database wins
No LLM endpoint configured LLM_DIRECT_URL unset Set it in .env
Service starts, then model calls fail with an empty model name EnvironmentFile= in a systemd unit Remove it. systemd's parser rejects KEY = with a space; let python-dotenv read .env
.env values missing in a shell script source .env aborts on unquoted parentheses Use python-dotenv, not source

Notes that save time later

Nothing here is global. Chat provider, embedding provider and database are three independent choices. Changing the chat model needs no re-indexing; changing the embedding model needs a full re-embed.

Prompts are files. prompts/<lang>/*.md. Set PROMPTS_RELOAD=1 to re-read them per call and iterate without restarting.

Keep deployment values out of the repo. PARLIAMENT_CONFIG, PROMPTS_DIR and CONTENT_DIR each point somewhere else on disk, so a deployment's own settings never appear in a diff against upstream.

database_query runs model-written SQL. Two layers restrict it to reads, but give it a SELECT-only database role anyway. See SECURITY.md.