Skip to content

Commit 2297623

Browse files
committed
feat: add kb/upsert and kb/query connectors for RAG (#153)
Native pgvector convenience connectors that compose with ai/embed, as thin sugar over a bring-your-own Postgres database (the step credential). - kb/upsert: store document text + embedding (+ optional JSONB metadata) into a pgvector table. Takes ai/embed's pgvector literal directly, supports single or batch (chunk) inserts via unnest, and idempotent ON CONFLICT (conflict_target) DO NOTHING. - kb/query: distance-ordered nearest-neighbour search (cosine/l2/ inner_product), returning the closest rows + a distance field. - Table/column names are validated as SQL identifiers to prevent injection (they can't be bound as parameters). The SQL-building logic is factored into pure prepareUpsert/prepareQuery functions with thorough unit tests (happy paths, arg alignment, injection rejection) — no DB needed. - Reuses postgres.go's connection + exec/select helpers; single postgres credential per step (no engine-managed store). - RAG example (rag-ingest/rag-ask/rag-kb-schema) and guide rewritten to use kb/*; connector reference documents both. Both workflows pass `mantle validate`. They don't manage schema; native chunking and metadata filtering on kb/query remain follow-ups on #153. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WnKgCRfMvFcZAkoVbr8k79
1 parent 1cd6347 commit 2297623

9 files changed

Lines changed: 647 additions & 70 deletions

File tree

.changeset/kb-connectors.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@mantle/engine": minor
3+
---
4+
5+
Add `kb/upsert` and `kb/query` connectors for retrieval-augmented generation. They are thin pgvector helpers over a Postgres database (the step's `credential`) that compose with `ai/embed`: `kb/upsert` stores document text + embedding (+ optional JSONB metadata), taking `ai/embed`'s pgvector literal directly and supporting single or batch (chunk) inserts with idempotent `ON CONFLICT`; `kb/query` runs distance-ordered nearest-neighbour search (cosine/l2/inner_product) and returns the closest rows. Table and column names are validated as SQL identifiers to prevent injection. The RAG example (`rag-ingest.yaml`, `rag-ask.yaml`, `rag-kb-schema.sql`) and guide now use these connectors. They do not manage schema (you create the pgvector table); native chunking and metadata filtering remain follow-ups on #153.

packages/engine/internal/connector/connector.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ func NewRegistry() *Registry {
3131
r.Register("slack/send", &SlackSendConnector{})
3232
r.Register("slack/history", &SlackHistoryConnector{})
3333
r.Register("postgres/query", &PostgresQueryConnector{})
34+
r.Register("kb/upsert", &KBUpsertConnector{})
35+
r.Register("kb/query", &KBQueryConnector{})
3436
r.Register("email/send", &EmailSendConnector{})
3537
r.Register("email/receive", &EmailReceiveConnector{})
3638
r.Register("email/move", &EmailMoveConnector{})
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
package connector
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"regexp"
8+
"strconv"
9+
"strings"
10+
11+
"github.com/jackc/pgx/v5"
12+
)
13+
14+
// The kb/* connectors are thin pgvector helpers over a Postgres database (the
15+
// step's `credential` is a postgres credential). They compose with ai/embed:
16+
// take the pgvector literal from ai/embed's `output.vector` / `output.vectors`
17+
// and store or query it, hiding the ::vector casts, distance operators, and
18+
// multi-row unnest inserts. They do not manage schema — create the table
19+
// yourself (see the RAG guide / rag-kb-schema.sql).
20+
21+
// kbIdentRe matches a safe SQL identifier, optionally schema-qualified. Table
22+
// and column names are interpolated into SQL (they cannot be bound as
23+
// parameters), so every identifier is validated against this before use.
24+
var kbIdentRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$`)
25+
26+
const (
27+
kbDefaultContentColumn = "content"
28+
kbDefaultEmbeddingColumn = "embedding"
29+
kbDefaultMetadataColumn = "metadata"
30+
kbDefaultTopK = 5
31+
kbMaxTopK = 1000
32+
)
33+
34+
// kbIdentParam reads an identifier param, applies a default when absent, and
35+
// validates it to prevent SQL injection through table/column names.
36+
func kbIdentParam(params map[string]any, key, def string) (string, error) {
37+
v, ok := params[key].(string)
38+
if !ok || v == "" {
39+
v = def
40+
}
41+
if v == "" {
42+
return "", fmt.Errorf("%s is required", key)
43+
}
44+
if !kbIdentRe.MatchString(v) {
45+
return "", fmt.Errorf("%s %q is not a valid SQL identifier", key, v)
46+
}
47+
return v, nil
48+
}
49+
50+
// kbStrings normalizes a "single or many" text param: `singleKey` (a string) or
51+
// `manyKey` (an array of strings). Exactly one must be present.
52+
func kbStrings(params map[string]any, singleKey, manyKey string) ([]string, error) {
53+
single, hasSingle := params[singleKey]
54+
many, hasMany := params[manyKey]
55+
if hasSingle == hasMany { // both or neither
56+
return nil, fmt.Errorf("exactly one of %s or %s is required", singleKey, manyKey)
57+
}
58+
if hasSingle {
59+
s, ok := single.(string)
60+
if !ok || s == "" {
61+
return nil, fmt.Errorf("%s must be a non-empty string", singleKey)
62+
}
63+
return []string{s}, nil
64+
}
65+
switch v := many.(type) {
66+
case []string:
67+
if len(v) == 0 {
68+
return nil, fmt.Errorf("%s must not be empty", manyKey)
69+
}
70+
return v, nil
71+
case []any:
72+
out := make([]string, 0, len(v))
73+
for i, item := range v {
74+
s, ok := item.(string)
75+
if !ok {
76+
return nil, fmt.Errorf("%s[%d] must be a string, got %T", manyKey, i, item)
77+
}
78+
out = append(out, s)
79+
}
80+
if len(out) == 0 {
81+
return nil, fmt.Errorf("%s must not be empty", manyKey)
82+
}
83+
return out, nil
84+
default:
85+
return nil, fmt.Errorf("%s must be an array of strings, got %T", manyKey, many)
86+
}
87+
}
88+
89+
// kbMetadata normalizes the optional metadata param into JSON strings aligned
90+
// with the rows. Returns present=false when no metadata param is set.
91+
func kbMetadata(params map[string]any, rows int) (jsons []string, present bool, err error) {
92+
single, hasSingle := params["metadata"]
93+
many, hasMany := params["metadatas"]
94+
if !hasSingle && !hasMany {
95+
return nil, false, nil
96+
}
97+
if hasSingle && hasMany {
98+
return nil, false, fmt.Errorf("set only one of metadata or metadatas")
99+
}
100+
101+
var objs []any
102+
if hasSingle {
103+
objs = []any{single}
104+
} else {
105+
arr, ok := many.([]any)
106+
if !ok {
107+
return nil, false, fmt.Errorf("metadatas must be an array of objects, got %T", many)
108+
}
109+
objs = arr
110+
}
111+
if len(objs) != rows {
112+
return nil, false, fmt.Errorf("metadata count %d does not match content count %d", len(objs), rows)
113+
}
114+
115+
out := make([]string, len(objs))
116+
for i, o := range objs {
117+
b, mErr := json.Marshal(o)
118+
if mErr != nil {
119+
return nil, false, fmt.Errorf("marshaling metadata[%d]: %w", i, mErr)
120+
}
121+
out[i] = string(b)
122+
}
123+
return out, true, nil
124+
}
125+
126+
// prepareUpsert validates params and builds the INSERT ... SELECT unnest(...)
127+
// statement plus its args. Pure (no DB) so it is fully unit-testable.
128+
func prepareUpsert(params map[string]any) (string, []any, error) {
129+
table, err := kbIdentParam(params, "table", "")
130+
if err != nil {
131+
return "", nil, err
132+
}
133+
contentCol, err := kbIdentParam(params, "content_column", kbDefaultContentColumn)
134+
if err != nil {
135+
return "", nil, err
136+
}
137+
embCol, err := kbIdentParam(params, "embedding_column", kbDefaultEmbeddingColumn)
138+
if err != nil {
139+
return "", nil, err
140+
}
141+
metaCol, err := kbIdentParam(params, "metadata_column", kbDefaultMetadataColumn)
142+
if err != nil {
143+
return "", nil, err
144+
}
145+
146+
contents, err := kbStrings(params, "content", "contents")
147+
if err != nil {
148+
return "", nil, err
149+
}
150+
vectors, err := kbStrings(params, "vector", "vectors")
151+
if err != nil {
152+
return "", nil, err
153+
}
154+
if len(vectors) != len(contents) {
155+
return "", nil, fmt.Errorf("vector count %d does not match content count %d", len(vectors), len(contents))
156+
}
157+
158+
metas, hasMeta, err := kbMetadata(params, len(contents))
159+
if err != nil {
160+
return "", nil, err
161+
}
162+
163+
var conflict string
164+
if ct, ok := params["conflict_target"].(string); ok && ct != "" {
165+
if !kbIdentRe.MatchString(ct) {
166+
return "", nil, fmt.Errorf("conflict_target %q is not a valid SQL identifier", ct)
167+
}
168+
conflict = fmt.Sprintf(" ON CONFLICT (%s) DO NOTHING", ct)
169+
}
170+
171+
var sb strings.Builder
172+
args := []any{contents, vectors}
173+
if hasMeta {
174+
fmt.Fprintf(&sb,
175+
"INSERT INTO %s (%s, %s, %s) SELECT c, e::vector, m::jsonb FROM unnest($1::text[], $2::text[], $3::text[]) AS u(c, e, m)%s",
176+
table, contentCol, embCol, metaCol, conflict)
177+
args = append(args, metas)
178+
} else {
179+
fmt.Fprintf(&sb,
180+
"INSERT INTO %s (%s, %s) SELECT c, e::vector FROM unnest($1::text[], $2::text[]) AS u(c, e)%s",
181+
table, contentCol, embCol, conflict)
182+
}
183+
return sb.String(), args, nil
184+
}
185+
186+
// kbDistanceOperator maps a metric name to its pgvector operator.
187+
func kbDistanceOperator(metric string) (string, error) {
188+
switch metric {
189+
case "", "cosine":
190+
return "<=>", nil
191+
case "l2", "euclidean":
192+
return "<->", nil
193+
case "inner_product", "ip":
194+
return "<#>", nil
195+
default:
196+
return "", fmt.Errorf("unknown metric %q (use cosine, l2, or inner_product)", metric)
197+
}
198+
}
199+
200+
// prepareQuery validates params and builds the nearest-neighbour SELECT plus
201+
// its args ($1 = query vector). Pure (no DB) so it is fully unit-testable.
202+
func prepareQuery(params map[string]any) (string, []any, error) {
203+
table, err := kbIdentParam(params, "table", "")
204+
if err != nil {
205+
return "", nil, err
206+
}
207+
embCol, err := kbIdentParam(params, "embedding_column", kbDefaultEmbeddingColumn)
208+
if err != nil {
209+
return "", nil, err
210+
}
211+
212+
vector, ok := params["vector"].(string)
213+
if !ok || vector == "" {
214+
return "", nil, fmt.Errorf("vector is required (a pgvector literal, e.g. from ai/embed output.vector)")
215+
}
216+
217+
metric, _ := params["metric"].(string)
218+
op, err := kbDistanceOperator(metric)
219+
if err != nil {
220+
return "", nil, err
221+
}
222+
223+
topK := kbDefaultTopK
224+
if v, ok := extractInt(params["top_k"]); ok {
225+
if v <= 0 {
226+
return "", nil, fmt.Errorf("top_k must be positive, got %d", v)
227+
}
228+
topK = v
229+
}
230+
if topK > kbMaxTopK {
231+
topK = kbMaxTopK
232+
}
233+
234+
// Columns to return: default to the content column; callers may request more.
235+
var cols []string
236+
if raw, ok := params["columns"]; ok {
237+
list, lErr := kbStringList(raw)
238+
if lErr != nil {
239+
return "", nil, fmt.Errorf("columns: %w", lErr)
240+
}
241+
for _, c := range list {
242+
if !kbIdentRe.MatchString(c) {
243+
return "", nil, fmt.Errorf("column %q is not a valid SQL identifier", c)
244+
}
245+
}
246+
cols = list
247+
} else {
248+
contentCol, cErr := kbIdentParam(params, "content_column", kbDefaultContentColumn)
249+
if cErr != nil {
250+
return "", nil, cErr
251+
}
252+
cols = []string{contentCol}
253+
}
254+
255+
selectList := strings.Join(cols, ", ")
256+
sql := fmt.Sprintf(
257+
"SELECT %s, (%s %s $1::vector) AS distance FROM %s ORDER BY %s %s $1::vector LIMIT %s",
258+
selectList, embCol, op, table, embCol, op, strconv.Itoa(topK))
259+
return sql, []any{vector}, nil
260+
}
261+
262+
func kbStringList(raw any) ([]string, error) {
263+
switch v := raw.(type) {
264+
case []string:
265+
return v, nil
266+
case []any:
267+
out := make([]string, 0, len(v))
268+
for i, item := range v {
269+
s, ok := item.(string)
270+
if !ok {
271+
return nil, fmt.Errorf("[%d] must be a string, got %T", i, item)
272+
}
273+
out = append(out, s)
274+
}
275+
return out, nil
276+
default:
277+
return nil, fmt.Errorf("must be an array of strings, got %T", raw)
278+
}
279+
}
280+
281+
// KBUpsertConnector implements kb/upsert: store document text + embedding
282+
// (and optional JSONB metadata) into a pgvector table, one or many rows.
283+
type KBUpsertConnector struct{}
284+
285+
func (c *KBUpsertConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) {
286+
connURL, err := extractPostgresURL(params)
287+
if err != nil {
288+
return nil, fmt.Errorf("kb/upsert: %w", err)
289+
}
290+
delete(params, "_credential")
291+
292+
query, args, err := prepareUpsert(params)
293+
if err != nil {
294+
return nil, fmt.Errorf("kb/upsert: %w", err)
295+
}
296+
297+
conn, err := pgx.Connect(ctx, connURL)
298+
if err != nil {
299+
return nil, fmt.Errorf("kb/upsert: connecting: %w", err)
300+
}
301+
defer conn.Close(ctx)
302+
303+
out, err := executeExec(ctx, conn, query, args)
304+
if err != nil {
305+
return nil, fmt.Errorf("kb/upsert: %w", err)
306+
}
307+
return out, nil
308+
}
309+
310+
// KBQueryConnector implements kb/query: nearest-neighbour search over a
311+
// pgvector table for a query embedding, returning the closest rows.
312+
type KBQueryConnector struct{}
313+
314+
func (c *KBQueryConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) {
315+
connURL, err := extractPostgresURL(params)
316+
if err != nil {
317+
return nil, fmt.Errorf("kb/query: %w", err)
318+
}
319+
delete(params, "_credential")
320+
321+
query, args, err := prepareQuery(params)
322+
if err != nil {
323+
return nil, fmt.Errorf("kb/query: %w", err)
324+
}
325+
326+
conn, err := pgx.Connect(ctx, connURL)
327+
if err != nil {
328+
return nil, fmt.Errorf("kb/query: connecting: %w", err)
329+
}
330+
defer conn.Close(ctx)
331+
332+
out, err := executeSelect(ctx, conn, query, args)
333+
if err != nil {
334+
return nil, fmt.Errorf("kb/query: %w", err)
335+
}
336+
return out, nil
337+
}

0 commit comments

Comments
 (0)