42 KiB
Riksdagen Sökplattform – Teknisk Översikt
En plattform för att utforska riksdagsdebatter och metadata. Backenden bygger på FastAPI, ArangoDB (med ArangoSearch), samt ChromaDB för vektorsök i chatten. Frontenden är en React/Vite-applikation skriven i TypeScript. LLM-funktionalitet kopplas in via _llm-paketet.
📋 Innehållsförteckning
- Systemöversikt
- Projektstruktur
- Backend-arkitektur
- Frontend-arkitektur
- Dataflöden
- Viktiga Python- och TypeScript-komponenter
- ArangoDB och ArangoSearch
- ChromaDB för vektorsök
- LLM-integration
- Utvecklingsflöde
- Felsökning och tips
🏗️ Systemöversikt
┌─────────────┐ HTTP ┌──────────────┐ Python ┌───────────────┐
│ Browser │ ─────────▶ │ FastAPI │ ───────────▶ │ ArangoDB │
│ (React) │ ◀───────── │ backend │ ◀─────────── │ (ArangoSearch) │
└─────────────┘ └──────┬───────┘ └───────────────┘
│
│ embeddings / metadata
▼
┌──────────────┐
│ ChromaDB │
└──────┬───────┘
│
▼
┌──────────────┐
│ _llm │ (OpenAI API-kompatibel klient)
└──────────────┘
- ArangoDB: primär databas. Sök görs via ArangoSearch-vyer i stället för SQL.
- ChromaDB: håller embeddings för chatten och mer avancerad semantisk sökning.
- LLM: genererar chattsvar med hjälp av
_llm.LLM(kopplad till t.ex. vLLM/OpenAI API).
📁 Projektstruktur
riksdagen/
├── arango_client.py # Initierar Arango-klienten (används av backend & scripts)
├── backend/
│ ├── app.py # FastAPI-app, routerregistrering
│ ├── search.py # SearchService med ArangoSearch-frågor
│ ├── chat.py # Chat-endpoints (LLM + Chroma)
│ ├── vector.py # Hjälpfunktioner för vektorsök (omfattar Chroma-anrop)
│ └── ... # Övriga routrar, scheman och konfigurationer
├── frontend/
│ ├── src/
│ │ ├── App.tsx # Inträde, växlar mellan sök/chat-lägen
│ │ ├── api.ts # Axios-klient mot `/api/*`
│ │ ├── types.ts # TypeScript-typer för API-svar
│ │ ├── components/ # UI-komponenter (SearchPanel, ChatPanel m.fl.)
│ │ └── styles.css # Globala stilar
│ └── vite.config.ts
├── scripts/
│ ├── ingest_arguments.py # Exempel: läser in data till ArangoDB
│ └── build_embeddings.py # Skapar embeddings och för in i Chroma
├── _arango/
│ ├── _arango.py # Wrapper kring python-arango (förbättrad initiering)
│ ├── queries/ # Samling av AQL/ArangoSearch-frågor
│ └── helpers.py # Hjälpfunktioner för AQL-byggande
├── _chromadb/
│ ├── client.py # Instansierar ChromaDB-klient
│ ├── collections.py # Hanterar samlingar (persistens, insättning, sök)
│ └── embeddings.py # Hjälp för integrering mot embeddingsgenerator
├── _llm/
│ └── llm.py # LLM-wrapper (OpenAI API-kompatibelt gränssnitt)
└── README.md
Tips: Utforska
_arango,_chromadboch_llmför att förstå hur databaskopplingar och LLM-anrop är kapslade.
🔧 Backend-arkitektur
backend/app.py
- Skapar FastAPI-instansen och exponerar endpoints under
/api. - Registrerar t.ex.
search_router,chat_router,meta_router. - Konfigurerar CORS så att frontenden kan nå backenden under utveckling och produktion.
backend/search.py
- Innehåller klassen
SearchService. SearchServiceanvänderarango_clientoch_arango-hjälpare för att köra ArangoSearch-frågor.- Vanliga metoder (namn kan skilja något, läs filen för exakt signatur):
search(...): bygger en ArangoSearch AQL-query med filter (parti, år, talare) samt scoring viaBM25()ellerTFIDF().get_metadata(...): hämtar distinkta värden till frontendens filter.get_document_by_id(...): används för detaljerad visning/snippets.
- Viktigt: ingen SQL används. AQL byggs dynamiskt och exekveras mot en ArangoSearch-vy (se
_arango/queries).
backend/chat.py
- Hanterar endpointen
POST /api/chat. - Steg i chatthanteringen:
- Plockar senaste användarfrågan.
- Anropar ChromaDB (via
_chromadb) för semantisk återhämtning. - Kompletterar kontext med Arango-sökresultat om relevant.
- Bygger prompt och anropar
_llm.LLM. - Returnerar AI-svaret plus metadata (t.ex. vilka dokument som användes).
- Har ofta även
POST /api/vector-searchsom låter frontenden testa Chroma-resultat separat.
backend/vector.py (eller motsvarande modul)
- Innehåller hjälpfunktioner för att:
- Normalisera text.
- Skicka query till ChromaDB och hämta top-k embeddings-matchningar.
- Eventuellt uppdatera/återskapa Chroma-samlingar.
⚛️ Frontend-arkitektur
App.tsx: växlar mellan sök- och chat-läge (t.ex. viaconst [mode, setMode] = useState<'search' | 'chat'>('search')).api.ts: axios-klient som exporterar:searchTalks(params)→POST /api/search.chat(messages, strategy)→POST /api/chat.vectorSearch(query)→POST /api/vector-search.getMeta()→GET /api/meta.
components/SearchPanel.tsx:- Kontrollerade inputs för fritext, år, parti m.m.
- Använder
react-query(useMutation/useQuery) för att trigga backend-anrop. - Visar resultat i
ResultsTable.tsx.
components/ChatPanel.tsx:- Håller konversation i state (lista av
{ role, content }). - Skickar hela historiken till chat-endpointen.
- Renderar streamen av svar.
- Håller konversation i state (lista av
types.ts:- Speglar backendens Pydantic-scheman med TypeScript-interfaces (
SearchRequest,SearchResponse,TalkResult,ChatRequest, etc.). - Bra översättning mellan Python-modeller och TypeScript.
- Speglar backendens Pydantic-scheman med TypeScript-interfaces (
🔄 Dataflöden
Sökning via ArangoSearch
- Frontend samlar filtervärden (t.ex.
query="skola",party="S",year=2023). api.searchTalksgör POST till/api/search.- FastAPI tar emot request → skapar
SearchService. SearchService.search:- Bygger AQL med
SEARCH-klasuler mot en ArangoSearch-vy (exempel:SEARCH ANALYZER(doc.anforande_text, "text_sv") LIKE "%skola%"). - Använder
SORT BM25(doc)ellerSORT doc.datumberoende på begärda sorteringsparametrar. - Begränsar resultat (
LIMIT offset, limit) och returnerar träffar + total count.
- Bygger AQL med
- Backend serialiserar dokumenten till JSON (inklusive metadata som färger, snippet, etc.).
- Frontend renderar resultat och uppdaterar paginering/statistikpaneler.
Chatt med LLM
- Användaren skriver fråga i chat-panelen.
api.chatskickar en lista av meddelanden ([{ role: 'user', content: ... }, ...]).backend/chat.py:- Tar ut senaste användarfrågan.
vector_search(...)i samma modul eller via_chromadbhämtar semantiskt relevanta avsnitt.- Kan komplettera svar med
SearchService-resultat för att säkerställa faktabas. - Lägger samman kontext och anropar
_llm.LLM.
- AI-svaret skickas tillbaka till frontenden och läggs på konversationen.
🧩 Viktiga Python- och TypeScript-komponenter
| Fil | Funktion |
|---|---|
arango_client.py |
Central Arango-klient. Återanvänds i backend och scripts. |
_arango/_arango.py |
Wrapper som läser miljövariabler, öppnar anslutningar och ger helpers för views/collections. |
_arango/queries/* |
Samlade AQL-snippets för sökning, metadata, detaljer. Läs dessa för att förstå exakta fält och filtreringslogik. |
_chromadb/client.py |
Skapar en Chroma-klient (kan vara persistent eller in-memory beroende på config). |
_chromadb/collections.py |
Hjälpfunktioner för att skapa/hämta Chroma-samlingar och göra query/add. |
_llm/llm.py |
LLM-wrapper. LLM(model='vllm') returnerar en klient med .chat(messages=...). |
backend/search.py |
SearchService med AQL/ArangoSearch-hantering och responsformatering. |
backend/chat.py |
Chat-endpoints, orchestrerar Chroma + LLM + (valfritt) Arango-sök. |
frontend/src/api.ts |
Samlar alla HTTP-anrop till backend. Lätt att mocka i tester. |
frontend/src/components/* |
React-komponenter. Kommentera gärna i koden var data kommer ifrån och vad som skickas tillbaka till backend. |
🗄️ ArangoDB och ArangoSearch
- Data ligger i ArangoDB-kollektioner, ofta
talks,speakers,debates, etc. - Views: en ArangoSearch-vy (t.ex.
talks_view) indexerar textfält med svenska analyser (text_sv). Detta gör attSEARCH-frågor kan använda ranking (BM25/TFIDF) och filtrera på fält. - AQL-byggstenar (se
_arango/queries):FOR doc IN talks_viewSEARCH ANALYZER(doc.anforande_text, "text_sv") LIKE "%..."%FILTER doc.party == @partySORT BM25(doc) DESCLIMIT @offset, @limit
- Metadata: Distinkta listor (ex. partier, år) hämtas via AQL
COLLECT.
Ny i ArangoSearch? Läs igenom
_arango/queriesoch Arangos officiella exempel. Notera att ArangoSearch skiljer sig från vanliga AQL-index genom att all textindexering sker i vyn, inte kollektionen.
🧠 ChromaDB för vektorsök
-
_chromadb/client.pykonfigurerar anslutningen (lokal fil, DuckDB + Parquet som standard). -
Embeddings genereras i
scripts/build_embeddings.py:- Hämtar tal från ArangoDB med
arango_client. - Delar upp text i chunkar (se
_chromadb/embeddings.pyför heuristiken). - Kör embeddingsgenerator (vanligen via
_llmeller egen modul). - Lagrar chunkar + metadata (
talk_id,speaker,party,source) i Chroma-samlingen.
- Hämtar tal från ArangoDB med
-
Vid chat:
vector_searchskickar användarfrågan som embedding till Chroma (collection.query(query_texts=[...], n_results=top_k)).- Resultatet innehåller
ids,documents,metadatas,distancessom matas in i prompten.
🤖 LLM-integration
-
_llm/llm.pyär huvudgränssnittet. Grundmönster:from _llm import LLM def generate_answer(prompt: str) -> str: llm = LLM(model="vllm") messages = [ {"role": "system", "content": "Du är en assistent som förklarar riksdagsdebatter sakligt."}, {"role": "user", "content": prompt}, ] response = llm.chat(messages=messages) return response -
Miljövariabler (
CHAT_MODEL,LLM_BASE_URL,LLM_API_KEY) styr målmodell och endpoint. -
Chat-endpointen täcker redan vanlig användning. För experiment: återanvänd samma mönster men bygg tydlig prompt av kontexten du hämtar.
Undvik onödiga
try/except. Fel bubbla upp så att du ser den faktiska orsaken (t.ex. nätverksproblem, fel API-nyckel).
🛠️ Utvecklingsflöde
| Steg | Befallning | Kommentar |
|---|---|---|
| Starta backend i utvecklingsläge | uvicorn backend.app:app --reload --port 8000 |
Körs i projektroten. Automatiskt reload vid filändring. |
| Starta frontend i dev-läge | cd frontend && npm run dev |
Öppna http://localhost:5173 |
| Bygg frontend för produktion | make frontend |
Skapar frontend/dist. |
| Ladda om nginx (om du serverar statiskt) | sudo systemctl reload nginx |
Efter ny frontend-build. |
| Kör embeddings-script | python -m scripts.build_embeddings --limit 500 |
Uppdaterar Chroma-samlingen. |
| Kör anpassade Arango-script | python -m scripts.ingest_arguments |
Exempel, kontrollera scriptens docstrings. |
🧪 Felsökning och tips
- Arango-anslutning: kör
python arango_client.pyför att testa att lösenord och URL stämmer. - Chroma-data: öppna
_chromadb/client.pyoch kontrollerapersist_directory. Rensa katalogen om du behöver resetta. - Chat-svar saknar fakta: kontrollera att
build_embeddings.pyhar körts nyligen och att Chroma-samlingen innehåller rätt metadata. - React/TypeScript:
- Använd
useQuery(för GET) ochuseMutation(för POST). - Håll komponenter små och bryt ut delkomponenter vid behov (t.ex.
FilterControls,ChatMessageList). - Typsätt props, state och API-svar med
types.tsför bättre editorstöd.
- Använd
- Python-stil:
- Lägg till type hints på funktioner (ex:
def search(self, params: SearchRequest) -> SearchResponse:). - Skriv docstrings som beskriver syfte, parametrar och returvärden.
- Undvik generella
try/except; analysera felen vid körning och fixa orsaken.
- Lägg till type hints på funktioner (ex:
🤔 Nästa steg
- Kommentera kod: lägg till docstrings i
SearchServiceoch chat-hjälpare. - Testa ArangoQueries: skapa enhetstester som kör små AQL-scenarier med testdata.
- Frontend-guidning: överväg inline-kommentarer i React-komponenterna för att förklara state-hantering.
- Observabilitet: logga vilka ArangoSearch-queries som körs vid olika filterkombinationer.
📎 Referenser
- ArangoDB Documentation – ArangoSearch
- ChromaDB Python API
- FastAPI – Depends och Pydantic
- React + TypeScript Cheatsheets
batch_embed()– Processar flera texter samtidigt
Exempel:
def get_embedding(text: str, model: str = "embeddinggemma") -> List[float]:
"""
Genererar en embedding-vektor för given text.
Args:
text: Texten att embedda
model: Ollama-modell (default: embeddinggemma)
Returns:
Lista med floats (3072 dimensioner för embeddinggemma)
"""
# Anropar http://192.168.1.11:11434/api/embeddings
# Returnerar vektor
backend/services/vector_store.py
Hanterar pgvector-operationer:
create_embeddings_table()– Skapartalk_embeddings-tabellstore_embedding()– Sparar embedding i databasquery_similar_talks()– Semantisk sökning
Database: backend/database.py
Viktigt: Denna fil skapar databaskopplingen.
# Skapar engine (kopplingen till PostgreSQL)
engine = create_engine(DATABASE_URL)
# Aktiverar pgvector-extension
with engine.connect() as conn:
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
# Session factory – används för att skapa databas-sessioner
SessionLocal = sessionmaker(bind=engine)
# Dependency för FastAPI – ger varje endpoint en databas-session
def get_db():
db = SessionLocal()
try:
yield db # Ger sessionen till endpointen
finally:
db.close() # Stänger sessionen efter request
Hur används det:
@router.post("/api/search")
def search_endpoint(request: SearchRequest, db: Session = Depends(get_db)):
# db är nu en aktiv databas-session
results = search_talks(db, ...)
⚛️ Frontend-arkitektur
Huvudfil: frontend/src/App.tsx
Detta är root-komponenten som renderas i index.html.
function App() {
const [mode, setMode] = useState<'search' | 'chat' | 'vector'>('search');
// mode styr vilket läge användaren är i:
// - 'search': Vanlig nyckelordssökning
// - 'chat': AI-chattläge
// - 'vector': Semantisk vektorsökning
return (
<div>
{/* Knappar för att växla läge */}
<button onClick={() => setMode('search')}>Sök</button>
<button onClick={() => setMode('chat')}>Chat</button>
{/* Visar olika komponenter baserat på mode */}
{mode === 'search' && <SearchPanel />}
{mode === 'chat' && <ChatPanel />}
{mode === 'vector' && <VectorSearchPanel />}
</div>
);
}
Viktiga React-koncept:
useState: Skapar state-variabel som kan ändras. Närmodeändras, re-renderar React komponenten.{ }: JavaScript-uttryck inuti JSX (React's HTML-liknande syntax)&&: Villkorlig rendering –condition && <Component />renderar bara om condition är true
API-klient: frontend/src/api.ts
Detta är hur frontend pratar med backend.
import axios from 'axios';
// Base URL – i produktion går detta via nginx reverse proxy
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
// axios-instans med konfigurerad base URL
const api = axios.create({
baseURL: API_BASE_URL,
headers: { 'Content-Type': 'application/json' }
});
// Funktion för att söka
export async function searchTalks(params: SearchParams): Promise<SearchResponse> {
const response = await api.post('/search', params);
return response.data; // Backend returnerar JSON
}
// Funktion för att chatta
export async function chat(messages: ChatMessage[]): Promise<ChatResponse> {
const response = await api.post('/chat', { messages });
return response.data;
}
TypeScript-koncept:
async/await: Hanterar asynkrona operationer (API-anrop)Promise<T>: Representerar ett framtida värde av typ Tinterface: Definierar shape av objekt (setypes.ts)
Komponenter
frontend/src/components/SearchPanel.tsx
Sökformulär med filter.
State-hantering:
const [query, setQuery] = useState(''); // Fritextsök
const [party, setParty] = useState(''); // Valt parti
const [year, setYear] = useState<number | ''>(''); // Valt år
// ...fler filter
// react-query mutation för att söka
const searchMutation = useMutation({
mutationFn: searchTalks,
onSuccess: (data) => {
// data innehåller SearchResponse från backend
// Skickas till ResultsTable via props
}
});
// När användaren klickar "Sök"
const handleSearch = () => {
searchMutation.mutate({
query,
party,
year,
// ...andra filter
});
};
React Query (useMutation):
- Hanterar API-anrop automatiskt
- Ger tillgång till
isLoading,error,data - Cachas resultat för snabbare återanvändning
JSX-exempel:
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Sök efter innehåll..."
/>
Hur det fungerar:
value={query}– Input-fältet visar värdet avquerystateonChange– När användaren skriver, uppdaterasquerymedsetQuery()- Detta är "controlled component" pattern i React
frontend/src/components/ResultsTable.tsx
Visar sökresultat i tabell.
interface ResultsTableProps {
results: TalkResult[]; // Array av anföranden från backend
total: int; // Totalt antal träffar
isLoading: boolean; // Om data laddas
}
function ResultsTable({ results, total, isLoading }: ResultsTableProps) {
if (isLoading) return <div>Laddar...</div>;
if (results.length === 0) return <div>Inga resultat</div>;
return (
<table>
<thead>
<tr>
<th>Datum</th>
<th>Talare</th>
<th>Parti</th>
<th>Innehåll</th>
</tr>
</thead>
<tbody>
{results.map((talk) => (
<tr key={talk.id}>
<td>{talk.datum}</td>
<td>{talk.talare}</td>
<td style={{ color: talk.party_color }}>
{talk.parti}
</td>
<td>{talk.snippet}</td>
</tr>
))}
</tbody>
</table>
);
}
Viktigt:
.map(): Loopar över array och returnerar JSX för varje elementkey={talk.id}: React behöver unika keys för att effektivt uppdatera listan- Props nedåt: Parent (SearchPanel) skickar data till child (ResultsTable)
frontend/src/components/ChatPanel.tsx
AI-chattgränssnitt.
State:
const [messages, setMessages] = useState<ChatMessage[]>([
{ role: 'assistant', content: 'Hej! Vad vill du veta om riksdagsdebatter?' }
]);
const [input, setInput] = useState(''); // Användarens input
const chatMutation = useMutation({
mutationFn: (msgs: ChatMessage[]) => chat(msgs),
onSuccess: (response) => {
// Lägg till AI-svaret i messages
setMessages(prev => [...prev, { role: 'assistant', content: response.reply }]);
}
});
const handleSend = () => {
// Lägg till användarens meddelande
const newMessages = [...messages, { role: 'user', content: input }];
setMessages(newMessages);
setInput(''); // Töm input-fält
// Skicka till backend
chatMutation.mutate(newMessages);
};
Rendering:
<div className="chat-messages">
{messages.map((msg, idx) => (
<div key={idx} className={msg.role}>
<strong>{msg.role === 'user' ? 'Du' : 'AI'}:</strong>
<p>{msg.content}</p>
</div>
))}
</div>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSend()}
/>
<button onClick={handleSend}>Skicka</button>
frontend/src/components/VectorSearchPanel.tsx
Semantisk sökning via embeddings.
Funktion:
const vectorMutation = useMutation({
mutationFn: (query: string) => vectorSearch(query, 10), // Top 10 resultat
onSuccess: (data) => {
// data.results innehåller liknande anföranden
}
});
const handleVectorSearch = () => {
vectorMutation.mutate(searchQuery);
};
Resultaten visas i samma format som ResultsTable.
TypeScript-typer: frontend/src/types.ts
Definierar data-strukturer som matchar backend.
// Matchar backend/schemas.py SearchRequest
export interface SearchParams {
query: string;
party?: string;
year?: number;
category?: string;
speaker?: string;
sort_by: string;
sort_order: string;
offset: number;
limit: number;
}
// Matchar backend TalkResult
export interface TalkResult {
id: string;
datum: string;
talare: string;
parti: string;
party_color: string;
anforandetext: string;
snippet: string;
kategori?: string;
}
// Matchar backend SearchResponse
export interface SearchResponse {
results: TalkResult[];
total: number;
offset: number;
limit: number;
}
Varför TypeScript?
- Fångar fel vid compile-time (innan kod körs)
- Autocomplete i editorn
- Dokumenterar API:t automatiskt
🔄 Dataflöde
Exempel: Användaren söker efter "skola"
-
Frontend (
SearchPanel.tsx):// Användaren skriver "skola" och klickar "Sök" handleSearch() { searchMutation.mutate({ query: "skola", party: "", year: "", sort_by: "date", sort_order: "desc", offset: 0, limit: 50 }); } -
API-klient (
api.ts):// axios skickar POST /api/search med JSON-body const response = await api.post('/search', params); -
nginx (reverse proxy):
location /api/ { proxy_pass http://127.0.0.1:8000/api/; }Omdirigerar till backend på port 8000.
-
Backend (
routes/search.py):@router.post("/api/search") def search_endpoint(request: SearchRequest, db: Session = Depends(get_db)): # Pydantic validerar request automatiskt results, total = search_talks( db=db, query=request.query, # "skola" filters={ "party": request.party, "year": request.year, # ... }, sort_by=request.sort_by, sort_order=request.sort_order, offset=request.offset, limit=request.limit ) return SearchResponse( results=results, total=total, offset=request.offset, limit=request.limit ) -
Service (
services/search_service.py):def search_talks(db, query, filters, ...): # Bygger SQL-fråga sql = f""" SELECT {', '.join(select_columns)} FROM talks WHERE anforandetext ILIKE %s """ params = [f"%{query}%"] # "%skola%" # Lägger till filter if filters.get('party'): sql += " AND parti = %s" params.append(filters['party']) # Kör fråga result = db.execute(text(sql), params) rows = result.fetchall() # Genererar snippets talks = [generate_snippet(row, query) for row in rows] return talks, len(talks) -
PostgreSQL:
SELECT id, datum, talare, parti, anforandetext, ... FROM talks WHERE anforandetext ILIKE '%skola%' ORDER BY datum DESC LIMIT 50 OFFSET 0; -
Backend returnerar JSON:
{ "results": [ { "id": "abc123", "datum": "2023-11-15", "talare": "Anna Andersson", "parti": "S", "party_color": "#E8112d", "snippet": "...vi måste investera i <mark>skola</mark>n..." }, ... ], "total": 234, "offset": 0, "limit": 50 } -
Frontend (
SearchPanel.tsx):onSuccess: (data) => { // react-query cachas svaret // ResultsTable uppdateras automatiskt med data.results } -
Rendering (
ResultsTable.tsx):{results.map(talk => ( <tr> <td>{talk.datum}</td> <td>{talk.talare}</td> <td style={{color: talk.party_color}}>{talk.parti}</td> <td dangerouslySetInnerHTML={{__html: talk.snippet}} /> </tr> ))}
🗄️ Databas & Schema
Huvudtabell: talks
Kolumner (definierat i info.py → select_columns):
select_columns = [
"id", # Unikt ID (UUID)
"datum", # Datum (YYYY-MM-DD)
"talare", # Talarens namn
"parti", # Partikod (S, M, SD, etc.)
"anforandetext", # Fullständig text
"kategori", # Debattämne
"year", # År (extrakt från datum)
"debatt_titel", # Debattens titel
"intressent_id", # Talarens ID
# ...fler kolumner
]
Indexering (viktigt för prestanda):
-- Trigram-index för fritextsökning
CREATE INDEX talks_text_trgm_idx ON talks USING gin(anforandetext gin_trgm_ops);
-- Index för filter
CREATE INDEX talks_year_idx ON talks(year);
CREATE INDEX talks_parti_idx ON talks(parti);
CREATE INDEX talks_datum_idx ON talks(datum);
Embeddings-tabell: talk_embeddings
Schema (skapas i services/vector_store.py):
CREATE TABLE talk_embeddings (
id SERIAL PRIMARY KEY,
talk_id TEXT NOT NULL, -- Referens till talks.id
chunk_index INTEGER, -- Vilken chunk (0, 1, 2, ...)
chunk_text TEXT, -- Texten för chunken
embedding vector(3072), -- pgvector-kolumn (embeddinggemma = 3072 dim)
metadata JSONB, -- Extra info (talare, parti, datum)
created_at TIMESTAMP DEFAULT NOW()
);
-- Index för snabb vector-sökning
CREATE INDEX talk_embeddings_vector_idx ON talk_embeddings
USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
-- Unique constraint för idempotens
CREATE UNIQUE INDEX talk_embeddings_unique_idx
ON talk_embeddings(talk_id, chunk_index);
Hur embeddings fungerar:
- Långt anförande delas upp i chunkar (~500 tokens vardera)
- Varje chunk får en embedding-vektor (3072 floats)
- Vektorn representerar semantisk mening
- Vid sökning: Generera embedding för frågan, hitta närmaste chunkar via cosine similarity
Exempel:
# Anförande från Anna Andersson (talk_id="abc123")
# Texten är 2000 ord → delas upp i 4 chunkar
talk_embeddings:
| id | talk_id | chunk_index | chunk_text | embedding | metadata |
|----|---------|-------------|---------------------|-------------------------|---------------------------------|
| 1 | abc123 | 0 | "Vi måste satsa..." | [0.23, -0.45, ...] | {"talare": "Anna", "parti": "S"}|
| 2 | abc123 | 1 | "Skolan behöver..." | [0.12, -0.67, ...] | {"talare": "Anna", "parti": "S"}|
| 3 | abc123 | 2 | "Lärare ska ha..." | [-0.34, 0.89, ...] | {"talare": "Anna", "parti": "S"}|
| 4 | abc123 | 3 | "Sammanfattning..." | [0.45, -0.12, ...] | {"talare": "Anna", "parti": "S"}|
Vector-sök SQL:
-- Hitta de 5 mest liknande chunkarna till frågan "lärarutbildning"
SELECT
talk_id,
chunk_text,
metadata,
embedding <=> :query_embedding AS distance -- Cosine distance
FROM talk_embeddings
ORDER BY embedding <=> :query_embedding
LIMIT 5;
Operator <=>: pgvector's cosine distance operator (mindre värde = mer likt).
🤖 AI/LLM-funktionalitet
LLM-wrapper: _llm/llm.py
Vad den gör: Abstraherar anrop till OpenAI-kompatibla API:er (t.ex. vLLM).
from _llm import LLM
llm = LLM(model='vllm') # eller 'gpt-4', 'gpt-3.5-turbo'
messages = [
{"role": "system", "content": "Du är en AI som hjälper med riksdagsdebatter."},
{"role": "user", "content": "Vad säger Socialdemokraterna om skatt?"}
]
response = llm.chat(messages=messages)
print(response) # AI-genererat svar
Konfiguration (miljövariabler):
export LLM_BASE_URL=http://localhost:8000/v1 # vLLM server
export LLM_API_KEY=placeholder # API-nyckel (om krävs)
export CHAT_MODEL=gpt-3.5-turbo # Modell att använda
Interna detaljer:
- Använder
openai-biblioteket under huven - Hanterar streaming (om aktiverat)
- Kan konfigurera temperature, max_tokens, etc.
Chat-flöde i backend
Fil: backend/routes/chat.py
@router.post("/api/chat")
def chat_endpoint(request: ChatRequest, db: Session = Depends(get_db)):
"""
AI-chat om riksdagsdebatter.
Steg:
1. Extrahera senaste användarfrågan
2. Sök relevant kontext (vector eller keyword)
3. Bygg prompt med systemmeddelande + kontext + fråga
4. Anropa LLM
5. Returnera svar
"""
# Extrahera senaste frågan
last_message = request.messages[-1]['content']
# Sök kontext
if request.strategy == 'vector':
context = vector_search_for_chat(db, last_message)
elif request.strategy == 'keyword':
context = keyword_search(db, last_message)
else: # auto
context = vector_search_for_chat(db, last_message)
if not context:
context = keyword_search(db, last_message)
# Bygg prompt
system_message = {
"role": "system",
"content": f"""Du är en AI-assistent som hjälper användare att förstå
svenska riksdagsdebatter.
Här är relevant information från debatterna:
{context}
Svara koncist baserat på denna information."""
}
messages = [system_message] + request.messages
# Anropa LLM
llm = LLM(model='vllm')
response = llm.chat(messages=messages)
return {"reply": response, "context_used": len(context)}
Embeddings-generering
Fil: scripts/build_embeddings.py
Vad scriptet gör:
- Hämtar anföranden från
talks-tabellen (batchvis medLIMITochOFFSET) - Delar upp varje anförande i chunkar
- Genererar embeddings via Ollama
- Sparar i
talk_embeddingsmedON CONFLICT DO NOTHING(idempotent)
Körning:
# Processa första 200 anföranden
python -m scripts.build_embeddings --limit 200
# Fortsätt från rad 200
python -m scripts.build_embeddings --limit 200 --offset 200
# Processa alla (varning: kan ta timmar!)
python -m scripts.build_embeddings --limit 1000000
Viktiga funktioner:
def process_talk(talk: dict, db: Session):
"""
Processar ett enda anförande.
Args:
talk: Dict med talk-data (id, anforandetext, talare, etc.)
db: Databas-session
"""
# Chunka texten
chunks = chunk_text(talk['anforandetext'], max_tokens=500)
# För varje chunk
for idx, chunk in enumerate(chunks):
# Generera embedding
embedding = get_embedding(chunk)
# Spara till databas
store_embedding(
db=db,
talk_id=talk['id'],
chunk_index=idx,
chunk_text=chunk,
embedding=embedding,
metadata={
"talare": talk['talare'],
"parti": talk['parti'],
"datum": talk['datum']
}
)
Progress-tracking:
Processar anföranden 0-200...
[█████████░░░░░░░░░░░] 47% (94/200) - ETA: 2m 34s
🔧 Utvecklingsflöde
Starta utvecklingsmiljö
1. Backend
# Terminal 1
cd /home/lasse/riksdagen
uvicorn backend.app:app --reload --port 8000
# --reload: Startar om automatiskt vid kodändring
# --port 8000: Lyssnar på port 8000
Verifiera: Gå till http://localhost:8000/docs (FastAPI Swagger UI)
2. Frontend (utvecklingsläge)
# Terminal 2
cd /home/lasse/riksdagen/frontend
npm run dev
# Startar Vite dev-server på http://localhost:5173
# Hot module replacement (HMR) aktiverat
3. Bygg för produktion
# Från projektroten
make frontend
# Eller manuellt
cd frontend
npm install # Installera dependencies
npm run build # Bygger till frontend/dist/
# Ladda om nginx
sudo systemctl reload nginx
Vanliga ändringsscenarier
Scenario 1: Lägg till ny filter-parameter
Backend (backend/schemas.py):
class SearchRequest(BaseModel):
# ...existing fields...
municipality: Optional[str] = None # Ny parameter
Backend (services/search_service.py):
def search_talks(db, query, filters, ...):
# ...existing code...
if filters.get('municipality'):
sql += " AND municipality = %s"
params.append(filters['municipality'])
Frontend (types.ts):
export interface SearchParams {
// ...existing fields...
municipality?: string; // Ny parameter
}
Frontend (SearchPanel.tsx):
const [municipality, setMunicipality] = useState('');
// I JSX
<select value={municipality} onChange={(e) => setMunicipality(e.target.value)}>
<option value="">Alla kommuner</option>
<option value="Stockholm">Stockholm</option>
{/* ...fler alternativ */}
</select>
// I handleSearch
searchMutation.mutate({
// ...existing params...
municipality
});
Efter ändringar:
- Starta om backend (Ctrl+C, kör uvicorn igen)
- Frontend uppdateras automatiskt (Vite HMR)
Scenario 2: Lägg till ny endpoint
Backend (backend/routes/stats.py – ny fil):
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from backend.database import get_db
router = APIRouter(prefix="/api", tags=["statistics"])
@router.get("/stats/party-distribution")
def get_party_distribution(db: Session = Depends(get_db)):
"""
Returnerar antal anföranden per parti.
"""
result = db.execute(text("""
SELECT parti, COUNT(*) as count
FROM talks
GROUP BY parti
ORDER BY count DESC
"""))
return [{"party": row[0], "count": row[1]} for row in result]
Backend (backend/app.py):
# ...existing imports...
from backend.routes.stats import router as stats_router
# ...existing code...
app.include_router(stats_router)
Frontend (api.ts):
export async function getPartyDistribution(): Promise<PartyDistribution[]> {
const response = await api.get('/stats/party-distribution');
return response.data;
}
Frontend (ny komponent StatsPanel.tsx):
import { useQuery } from '@tanstack/react-query';
import { getPartyDistribution } from '../api';
function StatsPanel() {
const { data, isLoading } = useQuery({
queryKey: ['party-distribution'],
queryFn: getPartyDistribution
});
if (isLoading) return <div>Laddar...</div>;
return (
<div>
<h2>Anföranden per parti</h2>
<ul>
{data?.map(item => (
<li key={item.party}>
{item.party}: {item.count}
</li>
))}
</ul>
</div>
);
}
Scenario 3: Ändra databas-schema
1. Manuell ändring (för utveckling):
-- Koppla till PostgreSQL
psql -U riksdagen -d riksdagen
-- Lägg till kolumn
ALTER TABLE talks ADD COLUMN sentiment VARCHAR(20);
-- Uppdatera info.py
# Lägg till "sentiment" i select_columns
2. För produktion: Använd migreringsverktyg (Alembic)
# Installera Alembic
pip install alembic
# Initiera
alembic init alembic
# Skapa migration
alembic revision -m "Add sentiment column"
# Redigera filen i alembic/versions/
def upgrade():
op.add_column('talks', sa.Column('sentiment', sa.String(20)))
def downgrade():
op.drop_column('talks', 'sentiment')
# Kör migration
alembic upgrade head
🐛 Felsökning
Problem: Frontend visar 502 Bad Gateway
Orsak: nginx kan inte nå backend.
Lösning:
# 1. Kolla att backend körs
curl http://127.0.0.1:8000/healthz
# Om inget svar:
uvicorn backend.app:app --reload --port 8000
# 2. Kolla nginx-konfiguration
sudo nginx -t
# 3. Kolla nginx-loggar
sudo tail -f /var/log/nginx/error.log
Problem: TypeScript-fel i frontend
Fel: Property 'X' does not exist on type 'Y'
Orsak: Type mismatch mellan frontend och backend.
Lösning:
- Kolla backend-schema i
backend/schemas.py - Uppdatera motsvarande interface i
frontend/src/types.ts - Kör
npm run buildför att verifiera
Problem: Inget resultat från vector-sök
Orsak: Inga embeddings genererade.
Lösning:
# 1. Kolla att tabellen finns
psql -U riksdagen -d riksdagen -c "SELECT COUNT(*) FROM talk_embeddings;"
# Om 0 rader:
python -m scripts.build_embeddings --limit 100
# 2. Kolla att Ollama körs
curl http://192.168.1.11:11434/api/tags
# 3. Testa embedding-generering
python -c "
from backend.services.embedding import get_embedding
emb = get_embedding('test')
print(len(emb)) # Ska ge 3072
"
Problem: Chat-svar är irrelevanta
Orsak: Dålig kontext från sök.
Debug:
# I backend/routes/chat.py, lägg till logging
import logging
logger = logging.getLogger(__name__)
# I chat_endpoint
logger.info(f"Context found: {len(context)} characters")
logger.info(f"First 200 chars: {context[:200]}")
# Kör backend med log-level DEBUG
uvicorn backend.app:app --reload --log-level debug
Lösning:
- Öka
top_ki vector-sök (fler resultat) - Förbättra chunkning-strategi (överlappande chunkar)
- Justera prompt i system-message
Problem: Långsam sökning
Orsak: Saknade index.
Lösning:
-- Kolla existerande index
SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'talks';
-- Lägg till saknade index
CREATE INDEX talks_text_trgm_idx ON talks USING gin(anforandetext gin_trgm_ops);
CREATE INDEX talks_year_idx ON talks(year);
CREATE INDEX talks_parti_idx ON talks(parti);
-- För vector-sök
CREATE INDEX talk_embeddings_vector_idx ON talk_embeddings
USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
Optimering:
# I services/search_service.py
# Begränsa kolumner som returneras
select_columns = ["id", "datum", "talare", "parti", "snippet"] # Inte hela anforandetext
# Använd paginering
LIMIT 50 OFFSET 0 # Hämta bara 50 rader åt gången
📝 Sammanfattning: Viktiga filer och deras roller
| Fil | Vad den gör |
|---|---|
backend/app.py |
Root för FastAPI-app, registrerar alla routes |
backend/database.py |
Databaskoppling, session-factory, aktiverar pgvector |
backend/routes/search.py |
Endpoint för nyckelordssökning |
backend/routes/chat.py |
Endpoints för AI-chat och vector-sök |
backend/services/search_service.py |
SQL-frågor för sökning, snippet-generering |
backend/services/vector_store.py |
pgvector-operationer, likhetssökning |
backend/services/embedding.py |
Chunkning, embedding-generering via Ollama |
frontend/src/App.tsx |
Root-komponent, mode-switching (sök/chat/vector) |
frontend/src/api.ts |
Axios-baserad API-klient |
frontend/src/components/SearchPanel.tsx |
Sökformulär med filter |
frontend/src/components/ChatPanel.tsx |
Chat-gränssnitt |
frontend/src/components/ResultsTable.tsx |
Tabellvisning av resultat |
scripts/build_embeddings.py |
Genererar embeddings för alla anföranden |
_llm/llm.py |
Wrapper för OpenAI-kompatibla LLM-anrop |
info.py |
Konstanter (partier, färger, kolumner) |
config.py |
Databas-URL och konfiguration |
🚀 Nästa steg: Förslag på förbättringar
- Streaming för chat: Visa AI-svaret ord för ord (WebSocket eller Server-Sent Events)
- Caching: Spara frekventa frågor i Redis för snabbare svar
- Feedback-loop: Använd användarfeedback för att förbättra sökresultat
- Avancerad filtrering: Datum-intervall, flera partier samtidigt
- Export-funktion: Ladda ner resultat som CSV/PDF
- Visualiseringar: Diagram för partier, tidslinjer, sentiment-analys
🆘 Hjälp och support
- Backend-loggar:
uvicorn backend.app:app --log-level debug - Frontend-konsol: Öppna DevTools (F12) i webbläsaren
- Databas-frågor:
psql -U riksdagen -d riksdagen - API-dokumentation:
http://localhost:8000/docs(när backend körs)
Lycka till med utvecklingen! 🎉