Skip to content

Commit 70af90e

Browse files
committed
feat: RAG follow-ups — kb/query metadata filtering, recursive chunking, Cohere Bedrock embeddings (#153)
Three composable follow-ups on the RAG primitives (#153): - kb/query metadata filtering: an optional `filter` object restricts the search to rows whose metadata contains it via JSONB containment (metadata @> $2::jsonb). The filter binds as a single parameter (no injection surface); a custom metadata_column is supported. Pure prepareQuery core with unit tests (placeholder positioning, custom column, empty-filter omission, non-object/injection errors). - text/chunk recursive mode: `unit: recursive` walks a separator hierarchy (paragraph -> line -> sentence -> word -> character) so chunks break on natural boundaries, then merges pieces up to chunk_size with chunk_overlap characters carried between chunks. Pure, Unicode-aware, unit-tested (sentence boundaries, hard-split fallback, overlap, size bound). rag-ingest now uses it. - Cohere Bedrock embeddings: ai/embed (provider bedrock) adds cohere.embed-english-v3 / cohere.embed-multilingual-v3 alongside Titan. Cohere batches natively (<=96 texts/call) and takes an input_type (search_document default / search_query / classification / clustering), threaded through EmbeddingRequest. Tests cover batching, default/invalid input_type, and order preservation. Docs (RAG guide, connector reference) and examples updated; changeset added.
1 parent 0f4a3f6 commit 70af90e

13 files changed

Lines changed: 568 additions & 33 deletions

File tree

.changeset/rag-followups.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@mantle/engine": minor
3+
---
4+
5+
RAG follow-ups on #153:
6+
7+
- **`kb/query` metadata filtering** — an optional `filter` object restricts the search to rows whose metadata contains it, via the JSONB containment operator (`metadata @> $2::jsonb`). The whole filter binds as a single parameter (no injection surface); a custom `metadata_column` is supported. Scope retrieval to a source, team, or tag alongside the vector search.
8+
- **Separator-aware `recursive` chunking**`text/chunk` gains `unit: recursive`, which walks a separator hierarchy (paragraph → line → sentence → word → character) so chunks break on natural boundaries instead of mid-sentence, then merges pieces up to `chunk_size` with `chunk_overlap` characters carried between chunks. The `rag-ingest` example now uses it.
9+
- **Cohere Bedrock embeddings**`ai/embed` (provider `bedrock`) now supports `cohere.embed-english-v3` and `cohere.embed-multilingual-v3` in addition to Amazon Titan. Cohere batches natively (up to 96 texts per call) and takes an `input_type` (`search_document` default / `search_query` / `classification` / `clustering`).
10+
11+
Token-accurate chunking remains a follow-up on #153.

packages/engine/internal/connector/chunk.go

Lines changed: 107 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"strings"
7+
"unicode/utf8"
78
)
89

910
// TextChunkConnector implements text/chunk: split a long document into
@@ -16,10 +17,20 @@ const (
1617
chunkDefaultOverlap = 0
1718
)
1819

19-
// chunkText splits text into fixed-size, optionally overlapping chunks. `unit`
20-
// is "chars" (default, Unicode-aware) or "words" (whitespace-separated). size
21-
// and overlap are counted in that unit. Pure — no I/O — so it is fully
22-
// unit-testable.
20+
// recursiveSeparators is the default hierarchy the "recursive" unit walks, from
21+
// coarsest (paragraph) to finest. The empty string is the terminal fallback: a
22+
// stretch with no separator left is hard-split by character window.
23+
var recursiveSeparators = []string{"\n\n", "\n", ". ", " ", ""}
24+
25+
// chunkText splits text into overlapping chunks. `unit` selects the strategy:
26+
// - "chars" (default, Unicode-aware) / "words": fixed-size sliding window,
27+
// size and overlap counted in that unit.
28+
// - "recursive": separator-aware split (paragraph → line → sentence → word →
29+
// character) that keeps chunks under `size` characters while preferring to
30+
// break on natural boundaries, then merges adjacent pieces up to `size` with
31+
// `overlap` characters carried between chunks.
32+
//
33+
// Pure — no I/O — so it is fully unit-testable.
2334
func chunkText(text string, size, overlap int, unit string) ([]string, error) {
2435
if strings.TrimSpace(text) == "" {
2536
return nil, fmt.Errorf("text must not be empty")
@@ -46,9 +57,100 @@ func chunkText(text string, size, overlap int, unit string) ([]string, error) {
4657
return sliceChunks(len(words), size, step, func(a, b int) string {
4758
return strings.Join(words[a:b], " ")
4859
}), nil
60+
case "recursive":
61+
return recursiveChunks(text, size, overlap, recursiveSeparators), nil
4962
default:
50-
return nil, fmt.Errorf("unknown unit %q (use chars or words)", unit)
63+
return nil, fmt.Errorf("unknown unit %q (use chars, words, or recursive)", unit)
64+
}
65+
}
66+
67+
// recursiveChunks splits text on a hierarchy of separators so chunks break on
68+
// natural boundaries, then greedily merges the pieces (with overlap) into
69+
// windows of at most `size` characters. size/overlap are counted in runes.
70+
func recursiveChunks(text string, size, overlap int, seps []string) []string {
71+
pieces := splitRecursive(text, size, seps)
72+
return mergeSplits(pieces, size, overlap)
73+
}
74+
75+
// splitRecursive breaks text into atomic pieces, each at most `size` runes where
76+
// possible, preferring the coarsest separator that appears. A piece still larger
77+
// than `size` after exhausting separators is hard-split by character window.
78+
// Separators are re-attached to the pieces so the text reconstructs faithfully.
79+
func splitRecursive(text string, size int, seps []string) []string {
80+
if utf8.RuneCountInString(text) <= size {
81+
if text == "" {
82+
return nil
83+
}
84+
return []string{text}
85+
}
86+
if len(seps) == 0 {
87+
return hardSplit(text, size)
88+
}
89+
sep, rest := seps[0], seps[1:]
90+
if sep == "" {
91+
return hardSplit(text, size)
92+
}
93+
parts := strings.Split(text, sep)
94+
var out []string
95+
for i, p := range parts {
96+
piece := p
97+
if i < len(parts)-1 { // keep the separator that we split on
98+
piece += sep
99+
}
100+
if piece == "" {
101+
continue
102+
}
103+
if utf8.RuneCountInString(piece) <= size {
104+
out = append(out, piece)
105+
} else {
106+
out = append(out, splitRecursive(piece, size, rest)...)
107+
}
51108
}
109+
return out
110+
}
111+
112+
// hardSplit chops text into consecutive windows of `size` runes (no overlap),
113+
// used as the terminal fallback for a stretch with no usable separator.
114+
func hardSplit(text string, size int) []string {
115+
runes := []rune(text)
116+
return sliceChunks(len(runes), size, size, func(a, b int) string {
117+
return string(runes[a:b])
118+
})
119+
}
120+
121+
// mergeSplits greedily concatenates pieces into chunks of at most `size` runes.
122+
// When a chunk fills, it starts the next one with a tail of the previous pieces
123+
// totalling up to `overlap` runes, so consecutive chunks share context.
124+
func mergeSplits(pieces []string, size, overlap int) []string {
125+
var chunks []string
126+
var cur []string
127+
curLen := 0
128+
129+
flush := func() {
130+
if len(cur) == 0 {
131+
return
132+
}
133+
if c := strings.TrimSpace(strings.Join(cur, "")); c != "" {
134+
chunks = append(chunks, c)
135+
}
136+
}
137+
138+
for _, p := range pieces {
139+
pl := utf8.RuneCountInString(p)
140+
if curLen+pl > size && len(cur) > 0 {
141+
flush()
142+
// Retain a trailing window of pieces up to `overlap` runes as the
143+
// start of the next chunk.
144+
for curLen > overlap && len(cur) > 0 {
145+
curLen -= utf8.RuneCountInString(cur[0])
146+
cur = cur[1:]
147+
}
148+
}
149+
cur = append(cur, p)
150+
curLen += pl
151+
}
152+
flush()
153+
return chunks
52154
}
53155

54156
// sliceChunks walks [0,n) in windows of `size` advancing by `step`, emitting

packages/engine/internal/connector/chunk_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,71 @@ func TestChunkText_Unicode(t *testing.T) {
6363
}
6464
}
6565

66+
func TestChunkText_RecursiveSentences(t *testing.T) {
67+
// With ". " available and a size that holds one sentence but not two, the
68+
// recursive splitter breaks on sentence boundaries.
69+
got, err := chunkText("Alpha beta. Gamma delta. Epsilon zeta.", 15, 0, "recursive")
70+
if err != nil {
71+
t.Fatalf("error: %v", err)
72+
}
73+
want := []string{"Alpha beta.", "Gamma delta.", "Epsilon zeta."}
74+
if strings.Join(got, "|") != strings.Join(want, "|") {
75+
t.Errorf("chunks = %v, want %v", got, want)
76+
}
77+
}
78+
79+
func TestChunkText_RecursiveFallbackHardSplit(t *testing.T) {
80+
// No usable separator (one long token): falls through to a character window.
81+
got, err := chunkText("abcdefgh", 3, 0, "recursive")
82+
if err != nil {
83+
t.Fatalf("error: %v", err)
84+
}
85+
want := []string{"abc", "def", "gh"}
86+
if strings.Join(got, "|") != strings.Join(want, "|") {
87+
t.Errorf("chunks = %v, want %v", got, want)
88+
}
89+
}
90+
91+
func TestChunkText_RecursiveOverlap(t *testing.T) {
92+
// Overlap carries a trailing word into the next chunk, so adjacent chunks
93+
// share the boundary token.
94+
got, err := chunkText("one two three four five six", 9, 4, "recursive")
95+
if err != nil {
96+
t.Fatalf("error: %v", err)
97+
}
98+
if len(got) < 2 || got[0] != "one two" || got[1] != "two three" {
99+
t.Errorf("chunks = %v, want first two [one two, two three]", got)
100+
}
101+
}
102+
103+
func TestChunkText_RecursiveShorterThanSize(t *testing.T) {
104+
got, err := chunkText("short text", 100, 10, "recursive")
105+
if err != nil {
106+
t.Fatalf("error: %v", err)
107+
}
108+
if len(got) != 1 || got[0] != "short text" {
109+
t.Errorf("chunks = %v, want [short text]", got)
110+
}
111+
}
112+
113+
func TestChunkText_RecursiveChunkSizeBound(t *testing.T) {
114+
// Every chunk stays within size + overlap runes (the documented bound).
115+
text := strings.Repeat("lorem ipsum dolor sit amet. ", 50)
116+
size, overlap := 60, 15
117+
got, err := chunkText(text, size, overlap, "recursive")
118+
if err != nil {
119+
t.Fatalf("error: %v", err)
120+
}
121+
if len(got) < 2 {
122+
t.Fatalf("expected multiple chunks, got %d", len(got))
123+
}
124+
for i, c := range got {
125+
if n := len([]rune(c)); n > size+overlap {
126+
t.Errorf("chunk %d has %d runes, exceeds size+overlap (%d)", i, n, size+overlap)
127+
}
128+
}
129+
}
130+
66131
func TestChunkText_Errors(t *testing.T) {
67132
cases := []struct {
68133
name string

packages/engine/internal/connector/embed.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ func (c *EmbeddingConnector) Execute(ctx context.Context, params map[string]any)
6060
if d, ok := extractInt(params["dimensions"]); ok && d > 0 {
6161
req.Dimensions = d
6262
}
63+
if it, ok := params["input_type"].(string); ok {
64+
req.InputType = it
65+
}
6366

6467
workflow, _ := params["_workflow"].(string)
6568
step, _ := params["_step"].(string)

packages/engine/internal/connector/kb.go

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -271,11 +271,60 @@ func prepareQuery(params map[string]any) (string, []any, error) {
271271
cols = []string{contentCol}
272272
}
273273

274+
// Optional metadata filter: a JSON object matched with the JSONB containment
275+
// operator (@>). The whole object is bound as one parameter, so there is no
276+
// injection surface — only the metadata column identifier is interpolated.
277+
args := []any{vector}
278+
where, err := kbFilterClause(params, len(args))
279+
if err != nil {
280+
return "", nil, err
281+
}
282+
if where.clause != "" {
283+
args = append(args, where.arg)
284+
}
285+
274286
selectList := strings.Join(cols, ", ")
275287
sql := fmt.Sprintf(
276-
"SELECT %s, (%s %s $1::vector) AS distance FROM %s ORDER BY %s %s $1::vector LIMIT %s",
277-
selectList, embCol, op, table, embCol, op, strconv.Itoa(topK))
278-
return sql, []any{vector}, nil
288+
"SELECT %s, (%s %s $1::vector) AS distance FROM %s%s ORDER BY %s %s $1::vector LIMIT %s",
289+
selectList, embCol, op, table, where.clause, embCol, op, strconv.Itoa(topK))
290+
return sql, args, nil
291+
}
292+
293+
// kbFilter is the built WHERE fragment plus its bound JSONB argument.
294+
type kbFilter struct {
295+
clause string // e.g. " WHERE metadata @> $2::jsonb", or "" when absent
296+
arg string // the JSON-encoded filter object (only set when clause != "")
297+
}
298+
299+
// kbFilterClause builds an optional JSONB-containment WHERE clause from the
300+
// `filter` param. The filter must be a JSON object; it is matched against the
301+
// metadata column with @> (does the row's metadata contain these key/values).
302+
// An absent or empty filter yields no clause. nextParam is the 1-based index of
303+
// the next free placeholder ($1 is always the query vector).
304+
func kbFilterClause(params map[string]any, nextParam int) (kbFilter, error) {
305+
raw, ok := params["filter"]
306+
if !ok || raw == nil {
307+
return kbFilter{}, nil
308+
}
309+
obj, ok := raw.(map[string]any)
310+
if !ok {
311+
return kbFilter{}, fmt.Errorf("filter must be a JSON object, got %T", raw)
312+
}
313+
if len(obj) == 0 {
314+
return kbFilter{}, nil // empty object matches everything — treat as no filter
315+
}
316+
metaCol, err := kbIdentParam(params, "metadata_column", kbDefaultMetadataColumn)
317+
if err != nil {
318+
return kbFilter{}, err
319+
}
320+
b, err := json.Marshal(obj)
321+
if err != nil {
322+
return kbFilter{}, fmt.Errorf("marshaling filter: %w", err)
323+
}
324+
return kbFilter{
325+
clause: fmt.Sprintf(" WHERE %s @> $%d::jsonb", metaCol, nextParam+1),
326+
arg: string(b),
327+
}, nil
279328
}
280329

281330
func kbStringList(raw any) ([]string, error) {

packages/engine/internal/connector/kb_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,65 @@ func TestPrepareQuery_MetricsAndColumns(t *testing.T) {
185185
}
186186
}
187187

188+
func TestPrepareQuery_MetadataFilter(t *testing.T) {
189+
sql, args, err := prepareQuery(map[string]any{
190+
"table": "kb_documents",
191+
"vector": "[0.1,0.2]",
192+
"columns": []any{"content", "metadata"},
193+
"filter": map[string]any{"source": "confluence", "team": "a"},
194+
"top_k": 3,
195+
})
196+
if err != nil {
197+
t.Fatalf("prepareQuery error: %v", err)
198+
}
199+
// The filter binds as $2 and the WHERE sits between FROM and ORDER BY.
200+
if !strings.Contains(sql, "FROM kb_documents WHERE metadata @> $2::jsonb ORDER BY") {
201+
t.Errorf("unexpected where clause: %s", sql)
202+
}
203+
if len(args) != 2 {
204+
t.Fatalf("args len = %d, want 2 (vector + filter)", len(args))
205+
}
206+
filterJSON := args[1].(string)
207+
if !strings.Contains(filterJSON, `"source":"confluence"`) || !strings.Contains(filterJSON, `"team":"a"`) {
208+
t.Errorf("filter arg = %q", filterJSON)
209+
}
210+
}
211+
212+
func TestPrepareQuery_FilterCustomColumnAndPlaceholder(t *testing.T) {
213+
// A custom metadata_column is interpolated; the filter placeholder follows
214+
// the vector ($1), i.e. $2.
215+
sql, _, err := prepareQuery(map[string]any{
216+
"table": "kb_documents",
217+
"vector": "[1]",
218+
"metadata_column": "meta",
219+
"filter": map[string]any{"k": "v"},
220+
})
221+
if err != nil {
222+
t.Fatalf("prepareQuery error: %v", err)
223+
}
224+
if !strings.Contains(sql, "WHERE meta @> $2::jsonb") {
225+
t.Errorf("unexpected filter clause: %s", sql)
226+
}
227+
}
228+
229+
func TestPrepareQuery_EmptyFilterOmitted(t *testing.T) {
230+
// An empty object matches everything; treat it as no filter (no WHERE, no arg).
231+
sql, args, err := prepareQuery(map[string]any{
232+
"table": "t",
233+
"vector": "[1]",
234+
"filter": map[string]any{},
235+
})
236+
if err != nil {
237+
t.Fatalf("prepareQuery error: %v", err)
238+
}
239+
if strings.Contains(sql, "WHERE") {
240+
t.Errorf("empty filter should not emit WHERE: %s", sql)
241+
}
242+
if len(args) != 1 {
243+
t.Errorf("args len = %d, want 1 (vector only)", len(args))
244+
}
245+
}
246+
188247
func TestPrepareQuery_TopKCap(t *testing.T) {
189248
sql, _, err := prepareQuery(map[string]any{"table": "t", "vector": "[1]", "top_k": 999999})
190249
if err != nil {
@@ -207,6 +266,8 @@ func TestPrepareQuery_Errors(t *testing.T) {
207266
{"zero top_k", map[string]any{"table": "t", "vector": "[1]", "top_k": 0}},
208267
{"sql injection in table", map[string]any{"table": "t; DROP TABLE x", "vector": "[1]"}},
209268
{"sql injection in column", map[string]any{"table": "t", "vector": "[1]", "columns": []any{"content", "x); DROP"}}},
269+
{"filter not an object", map[string]any{"table": "t", "vector": "[1]", "filter": "source=x"}},
270+
{"sql injection in metadata_column", map[string]any{"table": "t", "vector": "[1]", "metadata_column": "m @> '{}'; DROP", "filter": map[string]any{"k": "v"}}},
210271
}
211272
for _, tc := range cases {
212273
t.Run(tc.name, func(t *testing.T) {

packages/engine/internal/connector/provider.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ type EmbeddingProvider interface {
1919
type EmbeddingRequest struct {
2020
Model string
2121
Inputs []string
22-
Dimensions int // 0 = provider default
22+
Dimensions int // 0 = provider default
23+
InputType string // provider-specific hint (Cohere: search_document/search_query/...); "" = provider default
2324
Credential map[string]string
2425
}
2526

0 commit comments

Comments
 (0)