{"slug": "build-a-rag-powered-database-assistant-with-postgresql-and-pgvector", "title": "Build a RAG-Powered Database Assistant with PostgreSQL and pgvector", "summary": "A developer has built a RAG-powered database assistant using PostgreSQL and pgvector, enabling natural-language queries to be answered in seconds. The system retrieves schema metadata via vector similarity search, generates SQL with an LLM, and executes it on a read-only connection, ensuring safety and explainability. The tutorial demonstrates how to set up the assistant, leveraging PostgreSQL's versatility and pgvector's production-grade vector search.", "body_md": "Build a RAG-Powered Database Assistant with PostgreSQL and pgvector\n\nTags: ai, database, postgres, tutorial\n\nThe Problem\n\nEvery company has the same conversation with their database:\n\n\"How many orders did we lose in the last 30 days?\" \"Which customers ordered more than three times but never left a review?\" \"Show me the revenue trend by region for the past quarter.\"\n\nThese questions are simpleblog_post_en.mdblog_post_en.md for a human analyst, but they never make it into a SQL query fast enough. Business teams wait for engineering. Engineering is busy. The query finally gets written three days later, and the answer is already stale.\n\nWhat if the database itself could answer natural-language questions in seconds?\n\nThat is exactly what this tutorial builds: a RAG-powered database assistant that retrieves relevant schema context, generates SQL with an LLM, and returns the answer directly.\n\nWhy RAG instead of \"just ask ChatGPT\"?\n\nSchema-aware: the LLM sees the actual tables, columns, and relationships in your database.\n\nUp-to-date: metadata changes are reflected immediately without retraining.\n\nSafe: the generated SQL runs on a read-only connection, so it cannot mutate data.\n\nExplainable: users can see which schema context was used to answer the question.\n\nWhy PostgreSQL + pgvector\n\nPostgreSQL is the most versatile production database in use today. It already supports JSON, full-text search, window functions, and rich indexes. Since 2023, the pgvector extension gives it production-grade vector search without adding another database to your stack.\n\nUsing the same database for both operational data and retrieval means:\n\nOne backup strategy, one monitoring stack, one set of credentials.\n\nSQL joins between metadata and your real tables.\n\nNo extra network hop to a separate vector store.\n\nArchitecture\n\nUser question\n\n|\n\nv\n\n[1] Embed question (text-embedding-3-small)\n\n|\n\nv\n\n[2] pgvector similarity search over schema_metadata\n\n|\n\nv\n\n[3] Build prompt: question + relevant tables/columns/examples\n\n|\n\nv\n\n[4] LLM generates SQL (read-only)\n\n|\n\nv\n\n[5] Execute SQL -> return answer + explanation\n\nThe key idea is that we are not storing documents; we are storing database schema metadata as the retrieval corpus. That is the difference between a generic chatbot and a database assistant.\n\nProject Setup\n\nPrerequisites\n\nPostgreSQL 15+ with pgvector installed\n\nPython 3.11+\n\nAn OpenAI-compatible API key\n\npip install \"psycopg[binary]\" openai\n\nimport os\n\nimport json\n\nimport psycopg\n\nfrom openai import OpenAI\n\nclient = OpenAI() # reads OPENAI_API_KEY from environment\n\nDB_URL = os.getenv(\n\n\"DATABASE_URL\",\n\n\"postgresql://postgres:postgres@localhost:5432/rag_db\",\n\n)\n\nStep 1: Enable pgvector and Create the Metadata Table\n\nwith psycopg.connect(DB_URL, autocommit=True) as conn:\n\nconn.execute(\"CREATE EXTENSION IF NOT EXISTS vector\")\n\nconn.execute(\"\"\"\n\nCREATE TABLE IF NOT EXISTS schema_metadata (\n\nid BIGSERIAL PRIMARY KEY,\n\nobject_name TEXT NOT NULL,\n\nobject_type TEXT NOT NULL, -- table | column | example\n\ndefinition TEXT NOT NULL, -- DDL or column description\n\ndescription TEXT NOT NULL, -- natural-language semantics\n\nembedding vector(1536) -- text-embedding-3-small\n\n);\n\n\"\"\")\n\nconn.execute(\"\"\"\n\nCREATE INDEX IF NOT EXISTS schema_metadata_embedding_idx\n\nON schema_metadata\n\nUSING hnsw (embedding vector_cosine_ops);\n\n\"\"\")\n\nThe HNSW index gives approximate nearest-neighbor search with sub-10ms latency on modest datasets. For a few thousand metadata rows, this is effectively instant.\n\nStep 2: Extract Schema Metadata from the Database\n\nInstead of manually writing table descriptions, we extract the real schema from information_schema:\n\ndef fetch_schema_metadata(conn) -> list[dict]:\n\nrows = conn.execute(\"\"\"\n\nSELECT\n\nc.table_name,\n\nc.column_name,\n\nc.data_type,\n\npg_catalog.col_description(\n\n('\"' || c.table_schema || '\".\"' || c.table_name || '\"')::regclass,\n\nc.ordinal_position\n\n) AS column_comment\n\nFROM information_schema.columns c\n\nWHERE c.table_schema = 'public'\n\nORDER BY c.table_name, c.ordinal_position\n\n\"\"\").fetchall()\n\n```\nmetadata = []\nfor table_name, column_name, data_type, comment in rows:\n    metadata.append({\n        \"object_name\": f\"{table_name}.{column_name}\",\n        \"object_type\": \"column\",\n        \"definition\": (\n            f\"Column: {column_name}\\n\"\n            f\"Type: {data_type}\\n\"\n            f\"Table: {table_name}\"\n        ),\n        \"description\": comment or f\"{column_name} column in {table_name} table\",\n    })\nreturn metadata\n```\n\nWe also add query examples. These teach the LLM the query style used by your team:\n\nQUERY_EXAMPLES = [\n\n{\n\n\"object_name\": \"example:monthly_revenue\",\n\n\"object_type\": \"example\",\n\n\"definition\": (\n\n\"Question: What is the monthly revenue?\\n\"\n\n\"SQL: SELECT DATE_TRUNC('month', created_at) AS month, \"\n\n\"SUM(total_amount) FROM orders GROUP BY 1 ORDER BY 1;\"\n\n),\n\n\"description\": \"Monthly revenue aggregation on orders\",\n\n},\n\n{\n\n\"object_name\": \"example:top_customers\",\n\n\"object_type\": \"example\",\n\n\"definition\": (\n\n\"Question: Who are the top 10 customers by spend?\\n\"\n\n\"SQL: SELECT c.name, SUM(o.total_amount) AS spend \"\n\n\"FROM customers c JOIN orders o ON o.customer_id = c.id \"\n\n\"GROUP BY c.id ORDER BY spend DESC LIMIT 10;\"\n\n),\n\n\"description\": \"Top customers by total order amount\",\n\n},\n\n]\n\nStep 3: Embed and Store Metadata\n\ndef embed_texts(texts: list[str]) -> list[list[float]]:\n\nresp = client.embeddings.create(\n\nmodel=\"text-embedding-3-small\",\n\ninput=texts,\n\n)\n\nreturn [item.embedding for item in resp.data]\n\ndef ingest_metadata():\n\nwith psycopg.connect(DB_URL) as conn:\n\nschema_metadata = fetch_schema_metadata(conn)\n\n```\ndocs = []\nfor item in schema_metadata + QUERY_EXAMPLES:\n    docs.append(f\"{item['object_name']}\\n{item['definition']}\\n{item['description']}\")\n\nvectors = embed_texts(docs)\n\nwith psycopg.connect(DB_URL) as conn:\n    conn.execute(\"TRUNCATE schema_metadata\")\n    for item, vector in zip(schema_metadata + QUERY_EXAMPLES, vectors):\n        conn.execute(\"\"\"\n            INSERT INTO schema_metadata\n                (object_name, object_type, definition, description, embedding)\n            VALUES (%s, %s, %s, %s, %s)\n        \"\"\", (\n            item[\"object_name\"],\n            item[\"object_type\"],\n            item[\"definition\"],\n            item[\"description\"],\n            vector,\n        ))\n```\n\nNote: vector accepts a Python list directly through psycopg; the extension serializes it to its native format.\n\nStep 4: Retrieve Relevant Schema Context\n\ndef retrieve_context(question: str, top_k: int = 5) -> str:\n\nq_vector = embed_texts([question])[0]\n\n```\nwith psycopg.connect(DB_URL) as conn:\n    rows = conn.execute(\"\"\"\n        SELECT object_name, object_type, definition, description,\n               1 - (embedding <=> %s::vector) AS similarity\n        FROM schema_metadata\n        ORDER BY embedding <=> %s::vector\n        LIMIT %s\n    \"\"\", (q_vector, q_vector, top_k)).fetchall()\n\ncontext_blocks = []\nfor name, obj_type, definition, description, sim in rows:\n    context_blocks.append(\n        f\"### {name} ({obj_type}, similarity={sim:.3f})\\n\"\n        f\"{definition}\\n{description}\"\n    )\nreturn \"\\n\\n\".join(context_blocks)\n```\n\nThe cosine operator <=> is the key line. It ranks schema fragments by semantic relevance to the user's question.\n\nStep 5: Generate SQL and Return an Answer\n\nSYSTEM_PROMPT = \"\"\"\n\nYou are a senior database analyst. Your job is to convert\n\nnatural-language questions into safe, correct PostgreSQL.\n\nRules:\n\ndef generate_sql(question: str, context: str) -> str:\n\nresponse = client.chat.completions.create(\n\nmodel=\"gpt-4o-mini\",\n\nmessages=[\n\n{\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n\n{\"role\": \"user\", \"content\": (\n\nf\"Database schema context:\\n{context}\\n\\n\"\n\nf\"Question: {question}\\n\\n\"\n\n\"Generate PostgreSQL.\"\n\n)},\n\n],\n\ntemperature=0,\n\n)\n\ncontent = response.choices[0].message.content\n\nreturn extract_sql(content)\n\ndef extract_sql(content: str) -> str:\n\nif \"\n\n`sql\" in content:`\n\nreturn content.split(\"\n\nsql\")[1].split(\"\n\n``` php\n\")[0].strip()\n    return content.strip()\n\ndef execute_read_only(sql: str) -> list[tuple]:\n    # The application user owns the data; the assistant\n    # connects with a read-only role in production.\n    with psycopg.connect(DB_URL) as conn:\n        cur = conn.execute(sql)\n        return cur.fetchall()\nPutting It All Together\ndef ask_database(question: str) -> None:\n    print(f\"Q: {question}\\n\")\n\n    context = retrieve_context(question)\n    print(f\"Retrieved context:\\n{context}\\n\")\n\n    sql = generate_sql(question, context)\n    print(f\"Generated SQL:\\n{sql}\\n\")\n\n    rows = execute_read_only(sql)\n    print(f\"Result rows: {len(rows)}\")\n    for row in rows[:10]:\n        print(row)\n\nif __name__ == \"__main__\":\n    ask_database(\"Show monthly revenue for the last six months.\")\nExample output:\n\nQ: Show monthly revenue for the last six months.\n\nGenerated SQL:\nSELECT DATE_TRUNC('month', created_at) AS month,\n       SUM(total_amount) AS revenue\nFROM orders\nWHERE created_at >= CURRENT_DATE - INTERVAL '6 months'\nGROUP BY 1\nORDER BY 1;\n\nResult rows:\n(datetime.date(2026, 2, 1), Decimal('128450.00'))\n(datetime.date(2026, 3, 1), Decimal('153900.25'))\n...\nProduction Optimization Tips\n1. Add a schema refresh job\nRun ingest_metadata() nightly, or hook it into your CI migration pipeline so new columns are searchable the same day they ship.\n\n2. Use a read-only database role\nCREATE ROLE assistant_readonly LOGIN PASSWORD '...';\nGRANT CONNECT ON DATABASE rag_db TO assistant_readonly;\nGRANT USAGE ON SCHEMA public TO assistant_readonly;\nGRANT SELECT ON ALL TABLES IN SCHEMA public TO assistant_readonly;\nALTER DEFAULT PRIVILEGES IN SCHEMA public\n    GRANT SELECT ON TABLES TO assistant_readonly;\nThe application should connect with this role, not the superuser.\n\n3. Cache embeddings\nEmbedding calls cost time and money. Cache by metadata content hash; re-embed only rows that changed.\n\n4. Add guardrails\nValidate the generated SQL with EXPLAIN before execution.\nReject queries containing forbidden keywords as a second safety net.\nLog every question, SQL, and result count for auditability.\n5. Let the LLM explain the result\nAfter execution, send the row summary back to the LLM and ask for a concise, business-friendly interpretation. This is what makes the tool feel useful to non-technical teams.\n\nWhen Not to Use This Pattern\nAd-hoc data exploration on massive tables: the LLM will write SELECT * or miss an index. Keep a human in the loop.\nFinancial reporting with strict audit requirements: use a governed BI tool instead.\nLow-latency APIs: LLM inference adds seconds. Pre-generate SQL for known questions or cache results.\nComplex domain semantics: if your schema names are cryptic, invest in comments first; retrieval is only as good as the metadata.\nConclusion\nThis pattern is not about replacing SQL. It is about making the database accessible to people who should not need to learn SQL to get an answer.\n\nThe stack is deliberately boring:\n\nPostgreSQL for storage and search\npgvector for vector similarity\nOpenAI-compatible embeddings and chat completions\nA Python script that ties them together\nThe result is a database assistant that understands your schema, respects your query style, and answers business questions in seconds. Start with one table and one example query. Add more metadata as you trust it, and your team will stop waiting for engineering to write the next report.\n\nFound this useful? Follow me for more practical AI + database engineering posts.\n```\n\n", "url": "https://wpnews.pro/news/build-a-rag-powered-database-assistant-with-postgresql-and-pgvector", "canonical_source": "https://dev.to/blockchainlab/build-a-rag-powered-database-assistant-with-postgresql-and-pgvector-328i", "published_at": "2026-07-31 10:01:59+00:00", "updated_at": "2026-07-31 10:06:29.892210+00:00", "lang": "en", "topics": ["artificial-intelligence", "large-language-models", "generative-ai", "ai-tools", "developer-tools"], "entities": ["PostgreSQL", "pgvector", "OpenAI", "text-embedding-3-small", "HNSW"], "alternates": {"html": "https://wpnews.pro/news/build-a-rag-powered-database-assistant-with-postgresql-and-pgvector", "markdown": "https://wpnews.pro/news/build-a-rag-powered-database-assistant-with-postgresql-and-pgvector.md", "text": "https://wpnews.pro/news/build-a-rag-powered-database-assistant-with-postgresql-and-pgvector.txt", "jsonld": "https://wpnews.pro/news/build-a-rag-powered-database-assistant-with-postgresql-and-pgvector.jsonld"}}