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.
310 lines
10 KiB
310 lines
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`. |
|
|
|
```bash |
|
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: |
|
|
|
```bash |
|
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: |
|
|
|
```bash |
|
psql -h localhost -U plenum -d plenum -f _postgres/schema.sql |
|
``` |
|
|
|
**Verify:** |
|
|
|
```bash |
|
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** |
|
|
|
```bash |
|
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** |
|
|
|
```bash |
|
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:** |
|
|
|
```bash |
|
.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: |
|
|
|
```bash |
|
.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-text` → `EMBEDDING_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:** |
|
|
|
```bash |
|
.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: |
|
|
|
```bash |
|
.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](PORTING.md) — you write a config file and an |
|
adapter. |
|
|
|
**Verify:** |
|
|
|
```bash |
|
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 |
|
|
|
```bash |
|
.venv/bin/uvicorn backend.app:app --port 8000 # API |
|
cd frontend && npm install && npm run dev # UI on :5173 |
|
``` |
|
|
|
**Verify the whole stack:** |
|
|
|
```bash |
|
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](../SECURITY.md).
|
|
|