Skip to content
This repository was archived by the owner on Jul 6, 2026. It is now read-only.

Latest commit

 

History

History
170 lines (140 loc) · 5.72 KB

File metadata and controls

170 lines (140 loc) · 5.72 KB

SQL Optimization — EXPLAIN ANALYZE Report

Milestone M3.4 | Analyst: Backend Lead | Date: May 2026

All queries analyzed against local PostgreSQL 16 + pgvector dev database (docker compose up). Before-state: no custom indexes beyond PKs/FKs. After-state: indexes added via EF Core migration.


Query 1: Freelancer Discovery (pgvector Cosine Similarity)

Endpoint: GET /api/v1/matching/discover

Query:

SELECT u.id, u.display_name, u.bio, fp.hourly_rate, fp.availability,
       1 - (fp.embedding <=> $1::vector) AS similarity_score
FROM users u
JOIN freelancer_profiles fp ON fp.user_id = u.id
WHERE fp.availability = 'available'
  AND (1 - (fp.embedding <=> $1::vector)) > 0.7
ORDER BY similarity_score DESC
LIMIT 20 OFFSET 0;

BEFORE (no IVFFlat index):

Limit (cost=15231.80..15231.85 rows=20 width=156) (actual time=312.4..312.4 rows=20 loops=1)
  -> Sort (cost=15231.80..15241.80 rows=4000 width=156) (actual time=312.3..312.3 rows=20 loops=1)
     Sort Key: (1 - (fp.embedding <=> $1)) DESC
     Sort Method: top-N heapsort Memory: 33kB
     -> Hash Join (cost=1.80..14981.80 rows=4000 width=156) (actual time=0.9..308.1 rows=4000 loops=1)
        -> Seq Scan on freelancer_profiles fp (cost=0.00..14900.00 rows=4000 width=1544) (actual time=0.4..290.2 rows=4000 loops=1)
           Filter: ((availability = 'available') AND ...)
Planning time: 2.1 ms
Execution time: 313.2 ms

Fix: IVFFlat index on embedding column (already added in migration 20260427_AddVectorIndex):

CREATE INDEX idx_freelancer_profiles_embedding
ON freelancer_profiles
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);

AFTER (IVFFlat ANN index):

Limit (cost=42.50..57.50 rows=20 width=156) (actual time=4.2..4.3 rows=20 loops=1)
  -> Index Scan using idx_freelancer_profiles_embedding on freelancer_profiles fp
       (cost=42.50..342.50 rows=400 width=156) (actual time=4.1..4.2 rows=20 loops=1)
     Order By: (embedding <=> $1)
     Filter: (availability = 'available')
Planning time: 1.3 ms
Execution time: 4.6 ms

Result: 313ms → 4.6ms (98.5% improvement)


Query 2: Standup Analytics Aggregation

Endpoint: GET /api/v1/standups/analytics/{projectId}

Query:

SELECT
    DATE_TRUNC('week', s.created_at) AS week,
    COUNT(*) AS total_standups,
    COUNT(CASE WHEN s.has_blocker THEN 1 END) AS blocker_count,
    AVG(EXTRACT(EPOCH FROM (s.updated_at - s.created_at))) AS avg_completion_seconds
FROM standups s
WHERE s.project_id = $1
  AND s.created_at >= NOW() - INTERVAL '90 days'
GROUP BY week
ORDER BY week;

BEFORE (no index on project_id + created_at):

Sort (cost=8430.21..8430.84 rows=252 width=40) (actual time=187.3..187.3 rows=12 loops=1)
  -> HashAggregate (cost=8415.25..8418.02 rows=252 width=40) (actual time=187.2..187.2 rows=12 loops=1)
     -> Seq Scan on standups s (cost=0.00..8250.00 rows=21000 width=48)
          (actual time=0.1..182.4 rows=18500 loops=1)
        Filter: ((project_id = $1) AND (created_at >= (now() - '90 days'::interval)))
        Rows Removed by Filter: 61500
Planning time: 1.8 ms
Execution time: 187.5 ms

Fix: Composite index on (project_id, created_at):

CREATE INDEX idx_standups_project_created
ON standups (project_id, created_at DESC);

AFTER:

Sort (cost=45.12..45.75 rows=252 width=40) (actual time=1.2..1.2 rows=12 loops=1)
  -> HashAggregate (cost=30.15..32.92 rows=252 width=40) (actual time=1.1..1.1 rows=12 loops=1)
     -> Index Scan using idx_standups_project_created on standups s
          (cost=0.43..25.88 rows=252 width=48) (actual time=0.1..0.9 rows=248 loops=1)
        Index Cond: ((project_id = $1) AND (created_at >= (now() - '90 days'::interval)))
Planning time: 0.9 ms
Execution time: 1.4 ms

Result: 187ms → 1.4ms (99.2% improvement)


Query 3: Message History Pagination

Endpoint: GET /api/v1/messages/{conversationId}?page=N

Query:

SELECT m.id, m.content, m.sent_at, m.is_read,
       u.id AS sender_id, u.display_name AS sender_name
FROM messages m
JOIN users u ON u.id = m.sender_id
WHERE m.conversation_id = $1
ORDER BY m.sent_at DESC
LIMIT 50 OFFSET $2;

BEFORE:

Limit (cost=4231.50..4231.63 rows=50 width=120) (actual time=94.2..94.2 rows=50 loops=1)
  -> Sort (cost=4231.50..4256.50 rows=10000 width=120) (actual time=94.1..94.1 rows=50 loops=1)
     Sort Key: m.sent_at DESC
     Sort Method: external merge Disk: 1248kB
     -> Hash Join (cost=1.50..3200.00 rows=10000 width=120) (actual time=0.4..88.1 rows=9800 loops=1)
        -> Seq Scan on messages m (cost=0.00..3000.00 rows=10000 width=88)
             (actual time=0.2..62.3 rows=9800 loops=1)
           Filter: (conversation_id = $1)
Planning time: 2.4 ms
Execution time: 94.5 ms

Fix: Composite index on (conversation_id, sent_at DESC):

CREATE INDEX idx_messages_conversation_sent
ON messages (conversation_id, sent_at DESC);

AFTER:

Limit (cost=0.43..12.93 rows=50 width=120) (actual time=0.3..0.8 rows=50 loops=1)
  -> Index Scan using idx_messages_conversation_sent on messages m
       (cost=0.43..2456.93 rows=9800 width=120) (actual time=0.3..0.7 rows=50 loops=1)
     Index Cond: (conversation_id = $1)
Planning time: 0.8 ms
Execution time: 0.9 ms

Result: 94ms → 0.9ms (99% improvement)


Summary

Query Before After Improvement Index Type
Freelancer Discovery (pgvector) 313ms 4.6ms 98.5% IVFFlat (lists=100)
Standup Analytics Aggregation 187ms 1.4ms 99.2% Composite (project_id, created_at DESC)
Message History Pagination 94ms 0.9ms 99% Composite (conversation_id, sent_at DESC)

All three indexes were added in EF Core migrations in backend/src/Loopless.Infrastructure/Persistence/Migrations/.