Examples v7

Pipelines are the primary way to define AI workflows in EDB Postgres AI. Each pipeline consists of one or more sequential steps where data flows linearly: the output of step 1 becomes the input for step 2, and so on.

Creating a pipeline (table source)

To create a pipeline using a standard Postgres table as your data source, use the aidb.create_pipeline() function. The following examples show how to set up both a single-step and a multi-step pipeline.

Single-step pipeline

This example creates a knowledge base from a Postgres table. After running the pipeline, you can query the resulting embeddings for semantic search.

SELECT aidb.create_pipeline(
  name => 'kb_pipeline_table',
  source => 'source_table',
  source_key_column => 'id',
  source_data_column => 'content',
  step_1 => 'KnowledgeBase',
  step_1_options => aidb.knowledge_base_config(
     model => 'bert',  -- this is a pre-defined locally running model
     data_format => 'Text'
   )
);

Multi-step pipeline

You can chain multiple operations. This example first parses HTML and then chunks the resulting text.

SELECT aidb.create_pipeline(
   name => 'html_processing_pipeline',
   source => 'web_data_table',
   source_key_column => 'id',
   source_data_column => 'html_content',
   step_1 => 'ParseHtml',
   step_2 => 'ChunkText'
);

Creating a pipeline (volume source)

If your data lives in external storage (like an S3 bucket), you must first define a storage location and a volume.

Step 1: Define storage and volume

Create the PGFS storage location and AIDB volume.

SELECT pgfs.create_storage_location('s3_bucket_location', 's3://my-ai-data',
    options => '{"region": "us-east-1", "skip_signature": "true"}'
);
SELECT aidb.create_volume('source_volume', 's3_bucket_location', '/', 'Text');

Step 2: Create the pipeline

SELECT aidb.create_pipeline(
   name => 'pipeline_from_s3',
   source => 'source_volume',
   step_1 => 'KnowledgeBase',
   step_1_options => aidb.knowledge_base_config(
     model => 'dummy',
     data_format => 'Text'
   )
);

Running and updating pipelines

Pipelines are disabled by default upon creation. You can trigger them manually or enable automatic processing.

Manual execution

Run the pipeline once on all existing data:

SELECT aidb.run_pipeline('kb_pipeline_table');

Enable auto-processing

Keep the pipeline up-to-date as source data changes:

SELECT aidb.update_pipeline(
  name => 'kb_pipeline_table',
  auto_processing => 'Live'
);

Monitoring and deletion

To view your existing pipelines and their configurations, query the aidb.pipelines view:

SELECT * FROM aidb.pipelines;
Output
      name      | source_type | source_schema |       source       | source_key_column | source_data_column | destination_type | destination_schema |       destination       | destination_key_column | destination_data_column |                                                 steps                                                  | auto_processing | batch_size | background_sync_interval |              owner_role             
----------------+-------------+---------------+--------------------+-------------------+--------------------+------------------+--------------------+-------------------------+------------------------+-------------------------+--------------------------------------------------------------------------------------------------------+-----------------+------------+--------------------------+--------------------------------------
 pipeline__8989 | Table       | public        | source_table__8989 | id                | content            | Table            | public             | pipeline_pipeline__8989 | source_id              | value                   | [{"options": {"max_length": null, "desired_length": 1000}, "operation": "ChunkText", "step_order": 1}] | Live            |         42 | @ 42 mins                | role_pipeline_management_single_step
(1 row)
SELECT name, source, auto_processing FROM aidb.pipelines;
Output
      name      |       source       | auto_processing
----------------+--------------------+-----------------
 pipeline__8989 | source_table__8989 | Live
(1 row)

Deleting a pipeline will also drop any destination tables generated by the pipeline steps. To delete a pipeline:

SELECT aidb.delete_pipeline('kb_pipeline_table');

End-to-end example: Knowledge base for RAG

This example builds a complete retrieval-augmented generation (RAG) flow over an internal support knowledge base, entirely in SQL and entirely local: vector retrieval finds candidate articles, a cross-encoder reranker refines their order, and a generation model synthesizes an answer grounded in the retrieved articles. A reduced version of this example appears on the AI pipelines landing page.

Step 1: Register the models

Each stage uses a different kind of model. The embedding model, bge-small-en-v1.5-f16, is pre-registered on every AIDB install, so only the reranking and generation models need registering. aidb.create_model() downloads and validates each GGUF file from Hugging Face (the reranker is ~420 MB, Llama-3.2-3B ~3.4 GB); the revision pins make the example reproducible — without them, the model repository's mutable main branch is used.

-- Reranking model (cross-encoder)
SELECT aidb.create_model(
    name     => 'bge-reranker-v2-m3-Q4',
    provider => 'llamacpp_reranking',
    config   => jsonb_build_object(
        'model',      'gpustack/bge-reranker-v2-m3-GGUF',
        'model_file', 'bge-reranker-v2-m3-Q4_K_M.gguf',
        'revision',   '3093af03b1a635e67b084b1d8c03c5f5e020fd05',
        'n_ctx',      2048
    )
);

-- Generation model (instruct LLM)
SELECT aidb.create_model(
    name     => 'llama-3.2-3b-instruct-Q8_0',
    provider => 'llamacpp_generate',
    config   => jsonb_build_object(
        'model',       'unsloth/Llama-3.2-3B-Instruct-GGUF',
        'model_file',  'Llama-3.2-3B-Instruct-Q8_0.gguf',
        'revision',    'e7d0997e49c9cb00d88b4c1a6a16aa894b0bbc31',
        'n_ctx',       8192,
        'temperature', 0.0
    )
);
Model sizing for the generation stage

1B-class generation models (including the pre-registered llama-3.2-1b-instruct-Q8_0) aren't reliable for the generation stage of this example — in testing, they recommended actions that contradict the question's constraints. Llama-3.2-3B-Instruct answers correctly and is still a modest local model.

Step 2: Create the source data

Sixteen short support articles about database operations. Several deliberately share vocabulary (disk space, vacuum, backups) — that's what makes the reranking stage visibly matter later:

CREATE TABLE support_articles (
    id    INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    title TEXT NOT NULL,
    body  TEXT NOT NULL
);

INSERT INTO support_articles (title, body) VALUES
('Reclaiming disk space with VACUUM FULL',
 'VACUUM FULL rewrites a table into a new file and returns unused disk space to the operating system. It takes an ACCESS EXCLUSIVE lock, so all reads and writes to the table are blocked until it completes.'),
('Reducing table bloat without downtime',
 'The pg_repack extension rebuilds bloated tables and indexes online. It holds only brief locks, so applications can continue reading and writing while space is reclaimed. It requires free disk space roughly equal to the table size.'),
('Monitoring database disk usage',
 'Use pg_database_size() and pg_total_relation_size() to track how much disk space databases and tables occupy. Configure alerts well before storage volumes reach capacity.'),
('Resolving "too many connections" errors',
 'The error "FATAL: sorry, too many clients already" means the max_connections limit is reached. Find idle sessions in pg_stat_activity and terminate them with pg_terminate_backend(), or introduce a connection pooler.'),
('Connection pooling with PgBouncer',
 'PgBouncer multiplexes thousands of client connections onto a small pool of database sessions. Transaction pooling mode provides the highest connection density for typical web application workloads.'),
('Diagnosing streaming replication lag',
 'Compare pg_current_wal_lsn() on the primary with pg_last_wal_replay_lsn() on the standby to measure replication lag. Common causes include network saturation and long-running queries on the standby holding back WAL replay.'),
('Creating a hot standby with pg_basebackup',
 'Run pg_basebackup to clone the primary server, then start the clone with a standby.signal file. The standby serves read-only queries while continuously replaying WAL from the primary.'),
('Replicating selected tables with logical replication',
 'Logical replication publishes row changes for selected tables to subscriber databases and keeps them continuously synchronized. Unlike streaming replication, it can replicate a subset of tables and works across major versions.'),
('Point-in-time recovery with WAL archiving',
 'With continuous WAL archiving enabled, you can restore a base backup and set recovery_target_time to roll the database forward to a specific moment, such as just before an accidental data deletion.'),
('Logical backups with pg_dump',
 'pg_dump exports a database or single tables to a portable archive that can be restored into newer PostgreSQL versions. Dumps are consistent snapshots but offer no incremental or point-in-time capability.'),
('Reading query plans with EXPLAIN ANALYZE',
 'EXPLAIN ANALYZE executes a query and reports the actual time spent in each plan node. Look for row-estimate mismatches and sequential scans on large tables to find missing indexes.'),
('Finding and removing unused indexes',
 'Query pg_stat_user_indexes for indexes with zero scans. Unused indexes slow down every write and consume disk space; dropping them is a safe, immediate win.'),
('Tuning work_mem for large sorts',
 'When sorts or hash joins exceed work_mem, they spill to temporary files on disk and slow down dramatically. Raise work_mem session-locally for reporting queries rather than globally.'),
('Restricting data access with row-level security',
 'Row-level security policies filter which rows each role can see or modify. Policies are enforced by the server itself, so they apply uniformly to every client and application.'),
('Auditing database activity with pgaudit',
 'The pgaudit extension produces detailed audit logs of reads, writes, and DDL for compliance requirements. Configure log classes narrowly to control log volume.'),
('Upgrading major versions with pg_upgrade',
 'pg_upgrade migrates a cluster to a new major version by relinking data files instead of dumping and reloading. With the --link option, downtime is typically minutes even for large clusters.');

Step 3: Create and run the knowledge base pipeline

SELECT aidb.create_pipeline(
    name               => 'support_kb',
    source             => 'support_articles',
    source_key_column  => 'id',
    source_data_column => 'body',
    step_1             => 'KnowledgeBase',
    step_1_options     => aidb.knowledge_base_config(
        model       => 'bge-small-en-v1.5-f16',
        data_format => 'Text'
    ),
    auto_processing    => 'Disabled'
);

SELECT aidb.run_pipeline('support_kb');

Step 4: Retrieval

Semantic search finds the right article even when the query shares few or no keywords with it. Paste raw error text, get the resolution:

SELECT a.title, r.distance::numeric(5,3) AS distance
FROM aidb.retrieve_key('support_kb',
        'application logs show: FATAL: sorry, too many clients already', 3) r
JOIN support_articles a ON a.id = r.key::int
ORDER BY r.distance;
Output
                  title                  | distance
-----------------------------------------+----------
 Resolving "too many connections" errors |    0.507
 Tuning work_mem for large sorts         |    0.844
 Connection pooling with PgBouncer       |    0.908
(3 rows)

Or describe a symptom that has no tool names or keywords in common with the answer:

SELECT a.title, r.distance::numeric(5,3) AS distance
FROM aidb.retrieve_key('support_kb',
        'A developer accidentally deleted rows from a table yesterday. How do we get the data back?', 3) r
JOIN support_articles a ON a.id = r.key::int
ORDER BY r.distance;
Output
                   title                   | distance
-------------------------------------------+----------
 Point-in-time recovery with WAL archiving |    0.795
 Reducing table bloat without downtime     |    0.806
 Finding and removing unused indexes       |    0.846
(3 rows)

(aidb.retrieve_text() returns the article body directly, with no join needed; the join variant is shown because titles read better in output tables.)

Step 5: Reranking

Demo question: "The database is running out of disk space, but we cannot take the application offline to fix it."

Vector search alone ranks Point-in-time recovery — clearly unhelpful for a disk-space problem — at #1, with the correct article (pg_repack) second in a near-tie:

SELECT a.title, r.distance::numeric(5,3) AS distance
FROM aidb.retrieve_key('support_kb',
        'The database is running out of disk space, but we cannot take the application offline to fix it.', 5) r
JOIN support_articles a ON a.id = r.key::int
ORDER BY r.distance;
Output
                   title                   | distance
-------------------------------------------+----------
 Point-in-time recovery with WAL archiving |    0.808   ← wrong
 Reducing table bloat without downtime     |    0.838   ← correct answer, near-tie
 Tuning work_mem for large sorts           |    0.851
 Monitoring database disk usage            |    0.865
 Resolving "too many connections" errors   |    0.883
(5 rows)

The cross-encoder reads the question and each article together and reorders decisively. aidb.rerank_text() returns (text, logit_score, id), where id is the 0-based position in the input array and a higher logit_score means more relevant — the id + 1 maps that index back into the 1-based SQL arrays:

WITH candidates AS (
    SELECT array_agg(a.title ORDER BY r.distance) AS titles,
           array_agg(a.body  ORDER BY r.distance) AS bodies
    FROM aidb.retrieve_key('support_kb',
            'The database is running out of disk space, but we cannot take the application offline to fix it.', 5) r
    JOIN support_articles a ON a.id = r.key::int
)
SELECT c.titles[r.id + 1] AS title, r.logit_score::numeric(7,3) AS logit_score
FROM candidates c,
     aidb.rerank_text('bge-reranker-v2-m3-Q4',
            'The database is running out of disk space, but we cannot take the application offline to fix it.',
            c.bodies) r
ORDER BY r.logit_score DESC;
Output
                   title                   | logit_score
-------------------------------------------+-------------
 Reducing table bloat without downtime     |      -2.742   ← promoted to #1, clear margin
 Monitoring database disk usage            |      -3.116
 Point-in-time recovery with WAL archiving |      -7.037   ← demoted
 Tuning work_mem for large sorts           |      -8.836
 Resolving "too many connections" errors   |      -9.216
(5 rows)

This is the classic two-stage retrieval pattern: over-fetch candidates cheaply with the vector index (5 here; 20–100 in production), then let the more accurate but more expensive cross-encoder pick the final order. A reranker runs a full model forward pass per candidate — never rerank a whole table.

Step 6: Generation

Full RAG in a single statement: retrieve 5 candidates, rerank and keep the best 3, and generate a grounded answer. The first two CTEs are exactly the Step-4 and Step-5 commands above. (\set is a psql feature — in other clients, inline the question text directly.)

\set question 'The database is running out of disk space, but we cannot take the application offline to fix it. What should we do?'

WITH candidates AS (
    -- Stage 1: vector search over the knowledge base (over-fetch 5 candidates)
    SELECT array_agg(a.title ORDER BY r.distance) AS titles,
           array_agg(a.body  ORDER BY r.distance) AS bodies
    FROM aidb.retrieve_key('support_kb', :'question', 5) r
    JOIN support_articles a ON a.id = r.key::int
),
reranked AS (
    -- Stage 2: cross-encoder reranking, keep the best 3
    SELECT c.titles[r.id + 1] AS title,
           c.bodies[r.id + 1] AS body,
           r.logit_score
    FROM candidates c,
         aidb.rerank_text('bge-reranker-v2-m3-Q4', :'question', c.bodies) r
    ORDER BY r.logit_score DESC
    LIMIT 3
),
context AS (
    SELECT string_agg('### ' || title || E'\n' || body, E'\n\n'
                      ORDER BY logit_score DESC) AS articles
    FROM reranked
)
-- Stage 3: grounded answer generation
SELECT aidb.generate_text(
    'llama-3.2-3b-instruct-Q8_0',
    E'Knowledge base articles:\n\n' || context.articles ||
    E'\n\nQuestion: ' || :'question' ||
    E'\n\nAnswer the question using only the facts in the articles above. Recommend one course of action and mention its caveats.',
    '{
        "system_prompt": "You are a database support assistant. Be accurate and concise; do not invent facts that are not in the provided articles.",
        "max_tokens": 250,
        "temperature": 0.0
    }'::json
) AS answer
FROM context;

The output (deterministic at temperature: 0.0 — repeat runs produce identical answers):

Based on the provided articles, I recommend using the `pg_repack` extension to reduce table
bloat without downtime.

According to the article, `pg_repack` rebuilds bloated tables and indexes online, holding only
brief locks, allowing applications to continue reading and writing while space is reclaimed.
This approach does not require taking the application offline.

However, there is a caveat: `pg_repack` requires free disk space roughly equal to the table
size. This means that you need to ensure there is sufficient free disk space available before
running `pg_repack`, which may require some planning and preparation.

No single article contains this answer: the model combined the constraint from the question ("cannot go offline") with facts from two competing articles (VACUUM FULL blocks reads and writes; pg_repack works online) and carried over the free-disk-space caveat — a synthesis, not a verbatim return.

The same statement works with any registered generation model, including hosted ones — only the model name changes. One caveat for hosted GPT-5.x reasoning models: omit "temperature" from the inference config; they reject the parameter.

Step 7: Teardown

SELECT aidb.delete_pipeline('support_kb');
DROP TABLE support_articles;
SELECT aidb.delete_model('llama-3.2-3b-instruct-Q8_0');
SELECT aidb.delete_model('bge-reranker-v2-m3-Q4');

(The embedding model isn't deleted — bge-small-en-v1.5-f16 is one of the pre-registered defaults.)

End-to-end example: Multi-step pipeline with intermediate storage

This end-to-end example demonstrates how to configure a multi-step pipeline in EDB Postgres AI that utilizes intermediate storage.

By defining an intermediate_destination, you can persist the results of individual steps (like text chunking) before they pass to the next stage (like summarization), which is useful for debugging or reusing processed data. It's off by default, and no step config helper (aidb.chunk_text_config(), and so on) has a parameter for it — both examples below add it as plain JSON alongside the helper's own options. See Intermediate storage for the full reference, including how this interacts with deduplicate at query time.

Key features of this example:

  • Multi-step workflow: Chains ChunkText (Step 1) into SummarizeText (Step 2).

  • Auto-processing: The pipeline automatically triggers whenever new data is inserted into the source table.

  • Intermediate storage: Demonstrates how to explicitly name an intermediate table versus allowing the system to auto-generate one.

Example 1: Named intermediate destination

In this scenario, we will manually name the table that stores the output of the chunking step.

  • Create a source table for your raw text:

    CREATE TABLE source_table_demo(
      id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
      content TEXT NOT NULL
    );
  • Define the pipeline with a custom intermediate table:

    SELECT * from aidb.create_pipeline(
      name => 'complex_pipeline_v1',
      source => 'source_table_demo',
      source_key_column => 'id',
      source_data_column => 'content',
      auto_processing => 'Live',
      step_1 => 'ChunkText',
      step_1_options => jsonb_build_object(
          'desired_length', 20,
          'intermediate_destination', jsonb_build_object(
              'enabled', true,
              'destination', 'my_custom_chunk_storage' -- Explicitly named
          )
      ),
      step_2 => 'SummarizeText'
    );
  • Insert data to trigger auto-processing:

    INSERT INTO source_table_demo (content)
    VALUES ('This is a long text example that will be split into segments for easier processing.');
  • View the chunks and final summaries:

    -- View intermediate chunked output
    SELECT * FROM my_custom_chunk_storage;
    
    -- View the final summarized output
    SELECT * FROM pipeline_complex_pipeline_v1;

Example 2: Automatic intermediate destination

If you enable intermediate storage but don't provide a name, the system generates a table following the pattern pipeline_[name]_step_[n].

  • Create a pipeline with auto-named intermediate storage:

    SELECT * from aidb.create_pipeline(
      name => 'auto_named_pipeline',
      source => 'source_table_demo_2',
      source_key_column => 'id',
      source_data_column => 'content',
      auto_processing => 'Live',
      step_1 => 'ChunkText',
      step_1_options => jsonb_build_object(
          'desired_length', 20,
          'intermediate_destination', jsonb_build_object('enabled', true)
      ),
      step_2 => 'SummarizeText'
    );
  • Access the auto-generated intermediate table:

    -- View intermediate chunked output (auto-generated table name)
    SELECT * FROM pipeline_auto_named_pipeline_step_1;

End-to-end example: Scanned PDFs to a searchable knowledge base

This example shows how to build a full pipeline that turns a collection of scanned PDF documents into a vector-searchable knowledge base. It uses three sequential steps:

  1. PdfToImage: renders each PDF page as an image.

  2. PerformOcr: extracts text from each rendered image using an OCR model.

  3. KnowledgeBase: embeds the extracted text and stores it for semantic search.

This pattern is ideal for scanned documents, legacy PDFs without a text layer, and any content where ParsePdf returns empty or low-quality text. By leveraging intermediate storage, you can inspect the OCR output before it feeds into the knowledge base, ensuring data quality and debugging any issues in the workflow.

Prerequisites

You need a registered NVIDIA NIM PaddleOCR model and a registered embedding model. If you don't have these yet, register them first:

-- Register the OCR model
SELECT aidb.create_model(
    'my_paddle_ocr_model',
    'nim_paddle_ocr',
    credentials => '{"api_key": "<NVIDIA_NIM_API_KEY>"}'::JSONB  -- or credentials_env to read this from an environment variable instead
);

The embedding model (bert) is pre-installed and needs no registration. If you're using a different model, register it here.

Step 1: Create the source table

Create a table to hold the raw PDF data:

CREATE TABLE scanned_pdfs (
    id      INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name    TEXT NOT NULL,
    pdf_data BYTEA NOT NULL
);

Step 2: Load PDFs into the table

Insert PDF documents as binary data. The example below uses pg_read_binary_file() to load files from the server's file system:

INSERT INTO scanned_pdfs (name, pdf_data)
VALUES ('quarterly_report.pdf', pg_read_binary_file('/path/to/quarterly_report.pdf'));

You can also load PDFs from an S3-compatible volume — see External storage and Creating a pipeline (volume source).

Step 3: Create the pipeline

SELECT aidb.create_pipeline(
    name               => 'scanned_pdf_kb',
    source             => 'scanned_pdfs',
    source_key_column  => 'id',
    source_data_column => 'pdf_data',
    step_1             => 'PdfToImage',
    step_1_options     => '{"dpi": 150}'::jsonb,
    step_2             => 'PerformOcr',
    step_2_options     => aidb.ocr_config(model => 'my_paddle_ocr_model'),
    step_3             => 'KnowledgeBase',
    step_3_options     => aidb.knowledge_base_config(
        model             => 'bert',
        data_format       => 'Text',
        distance_operator => 'Cosine'
    ),
    auto_processing    => 'Live'
);

With auto_processing set to Live, the pipeline will automatically run whenever new PDFs are inserted into the scanned_pdfs table.

Step 4: Run the pipeline on existing data

If you inserted rows before enabling the pipeline, process them now:

SELECT aidb.run_pipeline('scanned_pdf_kb');

Step 5: Query the knowledge base

After the pipeline finishes, query the results using semantic search:

SELECT * FROM aidb.retrieve_text(
    'scanned_pdf_kb',
    'quarterly revenue figures',
    5  -- return top 5 matches
);

Each row in the result corresponds to a text block extracted from a PDF page, ranked by semantic similarity to your query.