Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Supports PostgreSQL 14-17

EXTENSION = pgedge_vectorizer
EXTVERSION = 1.1
EXTVERSION = 1.2

# Extension module and data files
MODULE_big = $(EXTENSION)
Expand All @@ -24,15 +24,17 @@ OBJS = src/pgedge_vectorizer.o \

DATA = sql/$(EXTENSION)--$(EXTVERSION).sql \
sql/$(EXTENSION)--1.0.sql \
sql/$(EXTENSION)--1.1.sql \
sql/$(EXTENSION)--1.0--1.1.sql \
sql/$(EXTENSION)--1.1--1.2.sql \
sql/$(EXTENSION)--1.0-beta2.sql \
sql/$(EXTENSION)--1.0-beta3.sql \
sql/$(EXTENSION)--1.0-beta1--1.0-beta2.sql \
sql/$(EXTENSION)--1.0-beta2--1.0-beta3.sql \
sql/$(EXTENSION)--1.0-beta3--1.0.sql

# Test configuration for pg_regress
REGRESS = setup chunking multibyte_chunking hybrid_chunking queue delete_truncate delete_truncate_pk pk_type_session max_retries vectorization multi_column maintenance edge_cases providers worker cleanup embedding pk_types stale_embeddings hybrid_test
REGRESS = setup chunking multibyte_chunking hybrid_chunking queue delete_truncate delete_truncate_pk pk_type_session max_retries vectorization multi_column maintenance edge_cases providers worker cleanup embedding pk_types stale_embeddings hybrid_test count_tokens per_table_model
REGRESS_OPTS = --inputdir=test --outputdir=test

# Documentation files (if any)
Expand Down
94 changes: 88 additions & 6 deletions docs/api_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ SELECT pgedge_vectorizer.enable_vectorization(
chunk_overlap INT DEFAULT NULL,
embedding_dimension INT DEFAULT NULL,
chunk_table_name TEXT DEFAULT NULL,
source_pk NAME DEFAULT NULL
source_pk NAME DEFAULT NULL,
provider TEXT DEFAULT NULL,
model TEXT DEFAULT NULL
);
```

Expand All @@ -29,6 +31,8 @@ SELECT pgedge_vectorizer.enable_vectorization(
- `embedding_dimension`: Vector dimension. When NULL (the default), the dimension is auto-detected by making a probe call to the configured embedding provider/model. Can be set explicitly to override auto-detection.
- `chunk_table_name`: Custom chunk table name (default: `{table}_{column}_chunks`)
- `source_pk`: Primary key column to use as the document identifier in the chunk table. When NULL (the default), the primary key column name and type are auto-detected from the table's primary key index via `pg_index`. Set explicitly to use a specific column (e.g., `'external_id'`).
- `provider`: Embedding provider for this vectorizer. When NULL (the default), `pgedge_vectorizer.provider` is used, and continues to be used as it changes.
- `model`: Embedding model for this vectorizer. When NULL (the default), `pgedge_vectorizer.model` is used, and continues to be used as it changes. Where `embedding_dimension` is not given, the probe asks about this model rather than the configured one.

**Primary Key Handling:**

Expand Down Expand Up @@ -107,15 +111,24 @@ Generate an embedding vector from query text.

```sql
SELECT pgedge_vectorizer.generate_embedding(
query_text TEXT
query_text TEXT,
provider TEXT DEFAULT NULL,
model TEXT DEFAULT NULL
);
```

**Parameters:**

- `query_text`: Text to generate an embedding for
- `provider`: Provider to use. NULL (the default) uses `pgedge_vectorizer.provider`.
- `model`: Model to use. NULL (the default) uses `pgedge_vectorizer.model`.

Returns: `vector` - The embedding vector using the configured provider
Returns: `vector` - The embedding vector

A query embedding must come from the same model as the embeddings it is
compared against, so name the model explicitly when searching a chunk table
whose vectorizer pins one. Vectors from two models are not comparable, and
nothing will report an error if you mix them.

**Example:**

Expand All @@ -135,15 +148,65 @@ LIMIT 5;

### detect_embedding_dimension()

Detect the embedding dimension of the currently configured provider/model.
Detect the embedding dimension of a provider and model.

```sql
SELECT pgedge_vectorizer.detect_embedding_dimension();
SELECT pgedge_vectorizer.detect_embedding_dimension(
provider TEXT DEFAULT NULL,
model TEXT DEFAULT NULL
);
```

**Parameters:**

- `provider`: Provider to probe. NULL (the default) uses `pgedge_vectorizer.provider`.
- `model`: Model to probe. NULL (the default) uses `pgedge_vectorizer.model`.

Returns: `INT` - The number of dimensions in the embedding vector

This function generates a probe embedding using the configured provider and model, and returns the dimension of the resulting vector. It is called automatically by `enable_vectorization()` when `embedding_dimension` is not specified.
This function generates a probe embedding and returns the dimension of the result, which means a real request to the provider. It is called automatically by `enable_vectorization()` and `set_embedding_model()` when `embedding_dimension` is not specified.

### set_embedding_model()

Change the embedding provider and model for one vectorizer.

```sql
SELECT pgedge_vectorizer.set_embedding_model(
source_table REGCLASS,
source_column NAME,
model TEXT,
provider TEXT DEFAULT NULL,
embedding_dimension INT DEFAULT NULL,
force_reembed BOOLEAN DEFAULT FALSE
);
```

**Parameters:**

- `source_table`, `source_column`: The vectorizer to change
- `model`: Model to use. NULL means inherit `pgedge_vectorizer.model`.
- `provider`: Provider to use. NULL means inherit `pgedge_vectorizer.provider`.
- `embedding_dimension`: Dimension of the new model. When NULL (the default), the new provider and model are probed for it, which is a real request. The chunk table's vector column is altered to match whether or not the vectorizer has any chunks yet, since a column left at the old width would fail every embedding written afterwards.
- `force_reembed`: Whether to clear the existing embeddings and requeue every chunk. Required to change a vectorizer that has any chunks.

Returns: `BIGINT` - The number of chunks requeued, which is zero unless the re-embed ran

Both columns are written to exactly what you pass, NULL included, so this is also how a vectorizer goes back to inheriting the GUCs. Where the effective provider and model do not actually change, nothing is requeued.

Changing a vectorizer that has chunks raises an error unless `force_reembed` is true. With it, every `embedding` is set to NULL, the column's dimension is altered if the new model differs, the vectorizer's queue rows are cleared and every chunk is requeued, all in one transaction. Chunk rows, their token counts, their sparse embeddings and the BM25 statistics are left alone, because none of them depends on the embedding model.

The refusal triggers on the model changing rather than on the dimension changing. See [Best Practices](best_practices.md) for why, and for what a re-embed costs.

**Example:**

```sql
-- Move one table to a local model, re-embedding what is already there
SELECT pgedge_vectorizer.set_embedding_model(
'articles'::regclass, 'body', 'nomic-embed-text',
provider => 'ollama',
force_reembed => true
);
```

### retry_failed()

Expand Down Expand Up @@ -328,6 +391,25 @@ SELECT pgedge_vectorizer.bm25_tokenize(query TEXT);

Returns: `TEXT[]` -- Array of distinct non-stopword terms.

### count_tokens()

Approximate the number of tokens in a piece of text. This is the same estimate
the chunking engine uses when it decides where a chunk ends, and it is what
gets stored in the `token_count` column of a chunk table, so it is useful for
working out why a given piece of text chunked the way it did.

```sql
SELECT pgedge_vectorizer.count_tokens(content TEXT);
```

Returns: `INT` -- The estimated token count, or `NULL` for `NULL` input.

The estimate counts UTF-8 characters and divides by four, rounding up, which
is a reasonable rule of thumb for English prose but no more than that: text
that tokenises unusually, such as code, dense punctuation or languages other
than English, will be some way out. Do not use it where an exact count
matters, such as checking a payload against a provider's hard token limit.

### show_config()

Display all pgedge_vectorizer configuration settings.
Expand Down
41 changes: 32 additions & 9 deletions docs/best_practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,31 @@ Proper chunking is essential for effective vector search because it balances sem
- An overlap of 10-20% (50-100 tokens) provides good context between adjacent chunks.
- Use a token-based strategy for general purpose content and the markdown strategy for structured documents.

**Changing an embedding model**

Changing the model for a table that already has embeddings is not free, and
`set_embedding_model()` refuses to do it silently for that reason. Passing
`force_reembed => true` clears every embedding and requeues every chunk, so the
whole table is embedded again: against a metered provider that is a bill, and
if the dimension changes it is also a rewrite of the chunk table. The chunks
Comment thread
coderabbitai[bot] marked this conversation as resolved.
themselves are not rebuilt, because neither chunk boundaries nor the BM25
statistics depend on the embedding model, so the sparse embeddings and token
counts survive untouched.

- The refusal keys on the model changing, not on the dimension changing. Two
models of the same width, such as `text-embedding-3-small` and
`text-embedding-ada-002`, both produce 1536 values, so swapping one for the
other would leave the old vectors in place, correctly shaped and meaningless
beside the new ones. Similarity between two models' vectors is noise, and
nothing else in the system would report a problem, which makes it the more
dangerous of the two cases.
- A model wider than 2000 dimensions cannot be used, because the HNSW index
that `enable_vectorization()` creates does not support one. That rules out
`text-embedding-3-large` at its full 3072, though it can be requested at a
smaller size from providers that support shortening.
- Pin the model rather than inheriting it wherever the embeddings matter, since
an inheriting vectorizer follows `pgedge_vectorizer.model` with no guard.

**Performance**

Optimizing performance ensures efficient resource utilization and faster embedding generation. These settings help minimize API costs while maintaining responsive processing speeds.
Expand All @@ -34,14 +59,12 @@ Effective data management ensures clean operations and provides flexibility when
- Use the `reprocess_chunks()` function to queue existing chunks that are missing embeddings.
- Use the `recreate_chunks()` function for a complete chunk regeneration, which deletes all existing chunks first.
- Each column gets independent chunk tables and triggers, so you can disable them selectively as needed.
- Settle on an embedding model before enabling vectorization, because the
model's dimension is fixed into the chunk table when the table is
created.
- Rebuild the vectorizer with `disable_vectorization(...,
drop_chunk_table => TRUE)` and then `enable_vectorization()` after
changing to a model of a different dimension, repeating this for every
vectorized column. Neither `recreate_chunks()` nor a
`disable_vectorization()` that keeps the chunk table alters the column,
so neither resolves the mismatch.
- Change a vectorizer's model with `set_embedding_model()`, which alters the
chunk table's vector column for you where the new model is a different
width. Dropping the chunk table and enabling vectorization again also works,
but throws away chunk rows, sparse embeddings and BM25 statistics that were
never wrong, and has to be repeated for every vectorized column.
`recreate_chunks()` is not an alternative: it rebuilds the chunks and leaves
the column exactly as it was.
- Budget for the provider cost of re-embedding an entire table before
changing the model on a populated one.
41 changes: 41 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,47 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Fixed

- The `token_count` recorded for each chunk is now computed the same way
everywhere. The chunking code in C rounds its four-characters-per-token
estimate up, whilst the plpgsql paths that actually write the column
open-coded the same estimate as `length(chunk_text) / 4`, which truncates,
so the two disagreed by a token on most chunks. Because `token_count` feeds
the BM25 document-length normalisation, hybrid search scored chunks written
by the trigger slightly differently from chunks written by the C chunker.
Both now call the new `count_tokens()` function, so there is one definition
of the rule.

### Added

- `pgedge_vectorizer.count_tokens(text)`, which exposes the chunking engine's
token estimate so you can see why a piece of text chunked the way it did.
- A per-vectorizer embedding provider and model
([#27](https://github.com/pgEdge/pgedge-vectorizer/issues/27)). Each
vectorizer may now name its own, through new `provider` and `model`
parameters on `enable_vectorization()` or through the new
`set_embedding_model()`, so one table can be embedded locally whilst another
goes to a hosted provider. Both default to `pgedge_vectorizer.provider` and
`pgedge_vectorizer.model`, so an existing installation is unaffected.
`set_embedding_model()` refuses to change a vectorizer that already has
embeddings unless `force_reembed` is passed, because vectors from two models
are not comparable and mixing them degrades search without failing.
- `generate_embedding()` and `detect_embedding_dimension()` accept an optional
provider and model, so a query can be embedded with the same model as the
chunks it will be compared against.

### Changed

- `generate_embedding(NULL)` now raises an error rather than returning NULL.
The function always meant to reject a NULL query, and said so in its own
code, but was declared `STRICT`, which returned NULL before that check could
run. It can no longer be `STRICT`, because a NULL provider or model has to
reach the function to mean "use the GUC".
- The chunk tables that `disable_vectorization()` drops are now processed in a
defined order, so a disable that covers several columns reports them the same
way twice.

## [1.1] - 2026-08-28

### Changed
Expand Down
69 changes: 69 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,75 @@ These settings configure the connection to your embedding provider, including th
[Troubleshooting](troubleshooting.md) document describes how to
recover.

### Per-vectorizer provider and model

The settings above are the defaults for the whole database, which is the right
thing when every table wants the same embeddings, and the wrong thing when they
do not: a table of short product titles and a table of long technical documents
are rarely well served by one model, and you may want one table embedded
locally through Ollama whilst another goes to a hosted provider. A vectorizer
can therefore name its own provider and model, and falls back to the settings
above where it does not.

Pin them when the vectorizer is created:

```sql
SELECT pgedge_vectorizer.enable_vectorization(
'articles'::regclass, 'body',
provider => 'ollama',
model => 'nomic-embed-text'
);
```

Or change them afterwards with `set_embedding_model()`, which takes the model
first because that is the argument you usually want:

```sql
SELECT pgedge_vectorizer.set_embedding_model(
'articles'::regclass, 'body', 'nomic-embed-text', provider => 'ollama');
```

Both settings live in `pgedge_vectorizer.vectorizers` as nullable columns,
where NULL means inherit. Inheritance is resolved when the work runs rather
than copied at creation, so a vectorizer that inherits follows the GUC as the
GUC changes.

`set_embedding_model()` always writes both columns to exactly what you pass,
and both default to NULL, so the shortest call resets both to inheriting:

```sql
-- Back to inheriting the provider and the model
SELECT pgedge_vectorizer.set_embedding_model('articles'::regclass, 'body', NULL);
Comment thread
dpage marked this conversation as resolved.
```

That cuts both ways: to change only the model whilst keeping a pinned
provider, name the provider again, or it reverts to inheriting alongside the
model.

```sql
-- Keep the pinned provider, change only the model
SELECT pgedge_vectorizer.set_embedding_model(
'articles'::regclass, 'body', 'mxbai-embed-large', provider => 'ollama');
```

Either call needs `force_reembed => true` if the vectorizer already has
embeddings and the effective model actually moves, as below.

`set_embedding_model()` refuses to change a vectorizer that already has
embeddings unless you pass `force_reembed => true`, which clears every
embedding and requeues every chunk. See
[Best Practices](best_practices.md) for what that costs and why the refusal
is not limited to changes of dimension.

!!! warning "Changing the GUC still moves every inheriting vectorizer"

The refusal above protects a vectorizer that has pinned its model. A
vectorizer that inherits has not, so changing
`pgedge_vectorizer.model` globally re-points every inheriting table at
once, with no guard and no re-embed, exactly as it did before this
setting existed. Pin the model on any vectorizer whose embeddings
matter.

## Worker Settings

These settings control the background workers that process the embedding queue, including concurrency, batch sizes, and retry behavior.
Expand Down
2 changes: 1 addition & 1 deletion pgedge_vectorizer.control
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# pgedge_vectorizer extension
comment = 'Asynchronous text chunking and vectorization for PostgreSQL'
default_version = '1.1'
default_version = '1.2'
module_pathname = '$libdir/pgedge_vectorizer'
relocatable = false
requires = 'vector'
Expand Down
Loading