Skip to content

Commit 9a7e8a8

Browse files
feat: add kb/upsert and kb/query connectors for RAG (#169)
1 parent 1cd6347 commit 9a7e8a8

9 files changed

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

0 commit comments

Comments
 (0)