@strav/rag 0.4.31 → 1.0.0-alpha.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +21 -23
- package/src/chunking/chunker.ts +7 -2
- package/src/chunking/fixed_size_chunker.ts +24 -8
- package/src/chunking/recursive_chunker.ts +89 -28
- package/src/console/index.ts +3 -0
- package/src/console/rag_console_provider.ts +17 -0
- package/src/console/rag_flush.ts +51 -0
- package/src/console/rag_list.ts +48 -0
- package/src/drivers/memory_driver.ts +110 -85
- package/src/drivers/pgvector_driver.ts +203 -109
- package/src/index.ts +46 -36
- package/src/migrations.ts +116 -0
- package/src/rag_error.ts +76 -0
- package/src/rag_manager.ts +289 -66
- package/src/rag_provider.ts +85 -7
- package/src/rag_vector_schema.ts +56 -0
- package/src/retrievable.ts +236 -145
- package/src/types.ts +80 -22
- package/src/vector_store.ts +45 -5
- package/src/commands/rag_flush.ts +0 -41
- package/src/commands/rag_ingest.ts +0 -45
- package/src/drivers/null_driver.ts +0 -21
- package/src/errors.ts +0 -21
- package/src/helpers.ts +0 -186
- package/stubs/config/rag.ts +0 -33
- package/tsconfig.json +0 -5
package/package.json
CHANGED
|
@@ -1,33 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/rag",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0-alpha.20",
|
|
4
|
+
"description": "Strav RAG module — vector store abstraction, pgvector + in-memory drivers, chunking strategies. Composes with @strav/brain for embeddings and @strav/database for persistence.",
|
|
4
5
|
"type": "module",
|
|
5
|
-
"
|
|
6
|
-
"
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
7
8
|
"exports": {
|
|
8
|
-
".": "./src/index.ts"
|
|
9
|
-
"./*": "./src/*.ts"
|
|
10
|
-
},
|
|
11
|
-
"strav": {
|
|
12
|
-
"commands": "src/commands"
|
|
9
|
+
".": "./src/index.ts"
|
|
13
10
|
},
|
|
14
11
|
"files": [
|
|
15
|
-
"src
|
|
16
|
-
"
|
|
17
|
-
"package.json",
|
|
18
|
-
"tsconfig.json"
|
|
12
|
+
"src",
|
|
13
|
+
"README.md"
|
|
19
14
|
],
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"
|
|
15
|
+
"engines": {
|
|
16
|
+
"bun": ">=1.3.14"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
25
20
|
},
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@strav/brain": "1.0.0-alpha.20",
|
|
23
|
+
"@strav/cli": "1.0.0-alpha.20",
|
|
24
|
+
"@strav/database": "1.0.0-alpha.20",
|
|
25
|
+
"@strav/kernel": "1.0.0-alpha.20"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@types/bun": ">=1.3.14"
|
|
29
29
|
},
|
|
30
|
-
"devDependencies":
|
|
31
|
-
"commander": "^14.0.3"
|
|
32
|
-
}
|
|
30
|
+
"devDependencies": null
|
|
33
31
|
}
|
package/src/chunking/chunker.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createChunker(config)` — factory that returns the right chunker
|
|
3
|
+
* for a `ChunkingConfig`. Apps that want a custom strategy build
|
|
4
|
+
* their own `Chunker` implementation and pass it directly into
|
|
5
|
+
* `rag.ingest({ chunker })` instead of going through config.
|
|
6
|
+
*/
|
|
7
|
+
|
|
1
8
|
import type { Chunker, ChunkingConfig } from '../types.ts'
|
|
2
9
|
import { FixedSizeChunker } from './fixed_size_chunker.ts'
|
|
3
10
|
import { RecursiveChunker } from './recursive_chunker.ts'
|
|
@@ -8,7 +15,5 @@ export function createChunker(config: ChunkingConfig): Chunker {
|
|
|
8
15
|
return new FixedSizeChunker(config.chunkSize, config.overlap)
|
|
9
16
|
case 'recursive':
|
|
10
17
|
return new RecursiveChunker(config.chunkSize, config.overlap, config.separators)
|
|
11
|
-
default:
|
|
12
|
-
return new RecursiveChunker(config.chunkSize, config.overlap)
|
|
13
18
|
}
|
|
14
19
|
}
|
|
@@ -1,23 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `FixedSizeChunker` — mechanical character-window chunking.
|
|
3
|
+
*
|
|
4
|
+
* Walks the content with a fixed window of `chunkSize` characters
|
|
5
|
+
* and steps forward by `chunkSize - overlap` each iteration. Cheap,
|
|
6
|
+
* predictable, agnostic to structure — best for content where
|
|
7
|
+
* paragraph / sentence boundaries don't carry meaning (logs, code
|
|
8
|
+
* tokens, raw transcript text).
|
|
9
|
+
*
|
|
10
|
+
* Apps with prose-style content should prefer `RecursiveChunker`,
|
|
11
|
+
* which respects paragraph and sentence boundaries.
|
|
12
|
+
*/
|
|
13
|
+
|
|
1
14
|
import type { Chunk, Chunker } from '../types.ts'
|
|
2
15
|
|
|
3
16
|
export class FixedSizeChunker implements Chunker {
|
|
4
17
|
constructor(
|
|
5
18
|
private readonly chunkSize: number = 512,
|
|
6
|
-
private readonly overlap: number = 64
|
|
7
|
-
) {
|
|
19
|
+
private readonly overlap: number = 64,
|
|
20
|
+
) {
|
|
21
|
+
if (chunkSize <= 0) throw new RangeError('FixedSizeChunker: chunkSize must be > 0.')
|
|
22
|
+
if (overlap < 0 || overlap >= chunkSize) {
|
|
23
|
+
throw new RangeError('FixedSizeChunker: overlap must satisfy 0 <= overlap < chunkSize.')
|
|
24
|
+
}
|
|
25
|
+
}
|
|
8
26
|
|
|
9
27
|
chunk(content: string): Chunk[] {
|
|
10
28
|
if (!content) return []
|
|
11
29
|
|
|
12
|
-
const
|
|
13
|
-
const step =
|
|
30
|
+
const out: Chunk[] = []
|
|
31
|
+
const step = this.chunkSize - this.overlap
|
|
14
32
|
|
|
15
33
|
let start = 0
|
|
16
34
|
let index = 0
|
|
17
|
-
|
|
18
35
|
while (start < content.length) {
|
|
19
36
|
const end = Math.min(start + this.chunkSize, content.length)
|
|
20
|
-
|
|
37
|
+
out.push({
|
|
21
38
|
content: content.slice(start, end),
|
|
22
39
|
index,
|
|
23
40
|
startOffset: start,
|
|
@@ -27,7 +44,6 @@ export class FixedSizeChunker implements Chunker {
|
|
|
27
44
|
start += step
|
|
28
45
|
if (end === content.length) break
|
|
29
46
|
}
|
|
30
|
-
|
|
31
|
-
return chunks
|
|
47
|
+
return out
|
|
32
48
|
}
|
|
33
49
|
}
|
|
@@ -1,15 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `RecursiveChunker` — splits on paragraph / sentence / word
|
|
3
|
+
* boundaries before falling back to fixed-size cuts. Better for
|
|
4
|
+
* prose and Markdown content than `FixedSizeChunker` because
|
|
5
|
+
* semantic boundaries survive.
|
|
6
|
+
*
|
|
7
|
+
* Strategy:
|
|
8
|
+
*
|
|
9
|
+
* 1. If the text fits in one chunk, return it whole.
|
|
10
|
+
* 2. Otherwise split on the first separator that produces
|
|
11
|
+
* pieces small enough to fit (defaults: paragraph → line →
|
|
12
|
+
* sentence → word).
|
|
13
|
+
* 3. Merge adjacent pieces greedily up to `chunkSize`.
|
|
14
|
+
* 4. Compute `startOffset` / `endOffset` by walking the merged
|
|
15
|
+
* pieces against the original content.
|
|
16
|
+
* 5. Apply a sliding overlap pass at the end so consecutive
|
|
17
|
+
* chunks share `overlap` characters of context — important
|
|
18
|
+
* for retrieval recall around chunk boundaries.
|
|
19
|
+
*
|
|
20
|
+
* Offsets are byte-accurate against the original content so apps
|
|
21
|
+
* that highlight retrieved passages in the source can slice
|
|
22
|
+
* directly with `content.slice(chunk.startOffset, chunk.endOffset)`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
1
25
|
import type { Chunk, Chunker } from '../types.ts'
|
|
2
26
|
|
|
3
|
-
const DEFAULT_SEPARATORS = ['\n\n', '\n', '. ', ' ']
|
|
27
|
+
const DEFAULT_SEPARATORS = ['\n\n', '\n', '. ', ' '] as const
|
|
4
28
|
|
|
5
29
|
export class RecursiveChunker implements Chunker {
|
|
6
|
-
private readonly separators: string[]
|
|
30
|
+
private readonly separators: readonly string[]
|
|
7
31
|
|
|
8
32
|
constructor(
|
|
9
33
|
private readonly chunkSize: number = 512,
|
|
10
34
|
private readonly overlap: number = 64,
|
|
11
|
-
separators?: string[]
|
|
35
|
+
separators?: readonly string[],
|
|
12
36
|
) {
|
|
37
|
+
if (chunkSize <= 0) throw new RangeError('RecursiveChunker: chunkSize must be > 0.')
|
|
38
|
+
if (overlap < 0 || overlap >= chunkSize) {
|
|
39
|
+
throw new RangeError('RecursiveChunker: overlap must satisfy 0 <= overlap < chunkSize.')
|
|
40
|
+
}
|
|
13
41
|
this.separators = separators ?? DEFAULT_SEPARATORS
|
|
14
42
|
}
|
|
15
43
|
|
|
@@ -19,25 +47,30 @@ export class RecursiveChunker implements Chunker {
|
|
|
19
47
|
return this.buildChunks(content, pieces)
|
|
20
48
|
}
|
|
21
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Recursive split. At each separator level, split the text and
|
|
52
|
+
* try to merge adjacent pieces back together greedily without
|
|
53
|
+
* exceeding `chunkSize`. Pieces that don't fit at this level
|
|
54
|
+
* recurse one separator deeper.
|
|
55
|
+
*/
|
|
22
56
|
private splitRecursive(text: string, separatorIndex: number): string[] {
|
|
23
57
|
if (text.length <= this.chunkSize) return [text]
|
|
24
58
|
|
|
25
59
|
const separator = this.separators[separatorIndex]
|
|
26
60
|
if (!separator) {
|
|
27
|
-
|
|
61
|
+
// Out of separators — hard-cut to `chunkSize`.
|
|
62
|
+
const out: string[] = []
|
|
28
63
|
for (let i = 0; i < text.length; i += this.chunkSize) {
|
|
29
|
-
|
|
64
|
+
out.push(text.slice(i, i + this.chunkSize))
|
|
30
65
|
}
|
|
31
|
-
return
|
|
66
|
+
return out
|
|
32
67
|
}
|
|
33
68
|
|
|
34
69
|
const parts = text.split(separator)
|
|
35
70
|
const merged: string[] = []
|
|
36
71
|
let current = ''
|
|
37
|
-
|
|
38
72
|
for (const part of parts) {
|
|
39
73
|
const candidate = current ? current + separator + part : part
|
|
40
|
-
|
|
41
74
|
if (candidate.length <= this.chunkSize) {
|
|
42
75
|
current = candidate
|
|
43
76
|
} else {
|
|
@@ -51,33 +84,61 @@ export class RecursiveChunker implements Chunker {
|
|
|
51
84
|
}
|
|
52
85
|
}
|
|
53
86
|
if (current) merged.push(current)
|
|
54
|
-
|
|
55
87
|
return merged
|
|
56
88
|
}
|
|
57
89
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Map merged pieces back onto offsets in the original content,
|
|
92
|
+
* then apply a sliding overlap so adjacent chunks share
|
|
93
|
+
* `overlap` characters of trailing context.
|
|
94
|
+
*/
|
|
95
|
+
private buildChunks(content: string, pieces: readonly string[]): Chunk[] {
|
|
96
|
+
if (pieces.length === 0) return []
|
|
97
|
+
|
|
98
|
+
// Walk the original content looking for each piece. The piece
|
|
99
|
+
// contents are substrings of the source; `indexOf(piece, cursor)`
|
|
100
|
+
// is sufficient because the recursive split preserves textual
|
|
101
|
+
// order.
|
|
102
|
+
const rawSpans: Array<{ start: number; end: number }> = []
|
|
103
|
+
let cursor = 0
|
|
104
|
+
for (const piece of pieces) {
|
|
105
|
+
const start = content.indexOf(piece, cursor)
|
|
106
|
+
if (start === -1) {
|
|
107
|
+
// Should never happen — splitRecursive only emits substrings —
|
|
108
|
+
// but guard against pathological input by falling back to
|
|
109
|
+
// appending at the cursor with the piece's literal length.
|
|
110
|
+
rawSpans.push({ start: cursor, end: cursor + piece.length })
|
|
111
|
+
cursor += piece.length
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
const end = start + piece.length
|
|
115
|
+
rawSpans.push({ start, end })
|
|
116
|
+
cursor = end
|
|
117
|
+
}
|
|
67
118
|
|
|
68
|
-
|
|
69
|
-
|
|
119
|
+
if (this.overlap === 0) {
|
|
120
|
+
return rawSpans.map((s, i) => ({
|
|
121
|
+
content: content.slice(s.start, s.end),
|
|
122
|
+
index: i,
|
|
123
|
+
startOffset: s.start,
|
|
124
|
+
endOffset: s.end,
|
|
125
|
+
}))
|
|
126
|
+
}
|
|
70
127
|
|
|
71
|
-
|
|
72
|
-
|
|
128
|
+
// Apply trailing overlap: each chunk after the first extends
|
|
129
|
+
// backward by `overlap` characters into the previous span so
|
|
130
|
+
// boundary context is duplicated.
|
|
131
|
+
const out: Chunk[] = []
|
|
132
|
+
for (let i = 0; i < rawSpans.length; i++) {
|
|
133
|
+
const span = rawSpans[i]!
|
|
134
|
+
const start = i === 0 ? span.start : Math.max(0, span.start - this.overlap)
|
|
135
|
+
out.push({
|
|
136
|
+
content: content.slice(start, span.end),
|
|
73
137
|
index: i,
|
|
74
|
-
startOffset,
|
|
75
|
-
endOffset:
|
|
138
|
+
startOffset: start,
|
|
139
|
+
endOffset: span.end,
|
|
76
140
|
})
|
|
77
|
-
|
|
78
|
-
searchFrom = pieceEnd
|
|
79
141
|
}
|
|
80
|
-
|
|
81
|
-
return chunks
|
|
142
|
+
return out
|
|
82
143
|
}
|
|
83
144
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `RagConsoleProvider` — declares the rag console commands.
|
|
3
|
+
*
|
|
4
|
+
* Apps add it to `bootstrap/providers.ts` alongside `RagProvider`.
|
|
5
|
+
* Separate provider (mirrors `QueueConsoleProvider`) so apps
|
|
6
|
+
* that don't use the CLI don't pay the cost of resolving the
|
|
7
|
+
* commands at boot.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ConsoleProvider } from '@strav/cli'
|
|
11
|
+
import { RagFlush } from './rag_flush.ts'
|
|
12
|
+
import { RagList } from './rag_list.ts'
|
|
13
|
+
|
|
14
|
+
export class RagConsoleProvider extends ConsoleProvider {
|
|
15
|
+
override readonly name = 'console.rag'
|
|
16
|
+
override readonly commands = [RagFlush, RagList] as const
|
|
17
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun strav rag:flush <collection> [--store=name] [--force]` —
|
|
3
|
+
* drop every vector in a collection on the active (or named)
|
|
4
|
+
* store.
|
|
5
|
+
*
|
|
6
|
+
* Use cases:
|
|
7
|
+
*
|
|
8
|
+
* - Wiping a corrupted index before re-ingest.
|
|
9
|
+
* - Cleaning up a dev / staging environment.
|
|
10
|
+
* - Recovering after a dimension / model change.
|
|
11
|
+
*
|
|
12
|
+
* The command confirms before running unless `--force` is set.
|
|
13
|
+
* Doesn't touch the source data — apps run their own re-ingest
|
|
14
|
+
* afterward, typically via `retrievable` repo's `reindexAll()`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Command, type ExecuteArgs, ExitCode } from '@strav/cli'
|
|
18
|
+
import { RagManager } from '../rag_manager.ts'
|
|
19
|
+
|
|
20
|
+
export class RagFlush extends Command {
|
|
21
|
+
static signature = 'rag:flush {collection} {--store=} {--force}'
|
|
22
|
+
static description = 'Delete every vector in a collection (on the active or --store= named store).'
|
|
23
|
+
static providers = ['config', 'logger', 'brain', 'rag']
|
|
24
|
+
|
|
25
|
+
override async execute({ args, flags }: ExecuteArgs): Promise<number> {
|
|
26
|
+
const collection = args.collection as string
|
|
27
|
+
const storeName = typeof flags.store === 'string' && flags.store.length > 0
|
|
28
|
+
? flags.store
|
|
29
|
+
: undefined
|
|
30
|
+
|
|
31
|
+
const manager = this.app.resolve(RagManager)
|
|
32
|
+
const fullCollection = manager.collectionName(collection)
|
|
33
|
+
const storeLabel = storeName ?? manager.config.default
|
|
34
|
+
|
|
35
|
+
if (flags.force !== true) {
|
|
36
|
+
const ok = await this.confirm(
|
|
37
|
+
`Delete every vector in collection "${fullCollection}" on store "${storeLabel}"? This is irreversible.`,
|
|
38
|
+
)
|
|
39
|
+
if (!ok) {
|
|
40
|
+
this.info('Aborted.')
|
|
41
|
+
return ExitCode.Success
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await manager.store(storeName).flush(fullCollection)
|
|
46
|
+
this.success(
|
|
47
|
+
`Flushed collection "${fullCollection}" on store "${storeLabel}".`,
|
|
48
|
+
)
|
|
49
|
+
return ExitCode.Success
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun strav rag:list` — print the configured RAG stores +
|
|
3
|
+
* chunker + embedding setup.
|
|
4
|
+
*
|
|
5
|
+
* Diagnostic only — no mutations. Useful for verifying that
|
|
6
|
+
* `config/rag.ts` parses correctly and that the registered
|
|
7
|
+
* driver names match what's expected.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Command, type ExecuteArgs, ExitCode } from '@strav/cli'
|
|
11
|
+
import { RagManager } from '../rag_manager.ts'
|
|
12
|
+
|
|
13
|
+
export class RagList extends Command {
|
|
14
|
+
static signature = 'rag:list'
|
|
15
|
+
static description = 'List configured RAG stores + embedding + chunking settings.'
|
|
16
|
+
static providers = ['config', 'logger', 'brain', 'rag']
|
|
17
|
+
|
|
18
|
+
override async execute(_args: ExecuteArgs): Promise<number> {
|
|
19
|
+
const manager = this.app.resolve(RagManager)
|
|
20
|
+
const config = manager.config
|
|
21
|
+
|
|
22
|
+
this.info(`Default store: ${config.default}`)
|
|
23
|
+
if (config.prefix) this.info(`Collection prefix: ${config.prefix}`)
|
|
24
|
+
|
|
25
|
+
this.info('')
|
|
26
|
+
this.info('Stores:')
|
|
27
|
+
for (const [name, store] of Object.entries(config.stores)) {
|
|
28
|
+
const flag = name === config.default ? ' (default)' : ''
|
|
29
|
+
this.info(` ${name}${flag}: driver=${store.driver}`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
this.info('')
|
|
33
|
+
this.info('Embedding:')
|
|
34
|
+
this.info(` provider: ${config.embedding.provider}`)
|
|
35
|
+
this.info(` model: ${config.embedding.model}`)
|
|
36
|
+
this.info(` dim: ${config.embedding.dimension}`)
|
|
37
|
+
|
|
38
|
+
this.info('')
|
|
39
|
+
this.info('Chunking:')
|
|
40
|
+
this.info(` strategy: ${config.chunking.strategy}`)
|
|
41
|
+
this.info(` chunkSize: ${config.chunking.chunkSize}`)
|
|
42
|
+
this.info(` overlap: ${config.chunking.overlap}`)
|
|
43
|
+
if (config.chunking.separators) {
|
|
44
|
+
this.info(` separators: ${JSON.stringify(config.chunking.separators)}`)
|
|
45
|
+
}
|
|
46
|
+
return ExitCode.Success
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -1,135 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MemoryDriver` — in-process `VectorStore` backed by `Map`s.
|
|
3
|
+
*
|
|
4
|
+
* Two real use cases:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Tests.** Apps test their retrieval logic without booting
|
|
7
|
+
* Postgres + pgvector. Reset between tests via
|
|
8
|
+
* `new MemoryDriver()`.
|
|
9
|
+
* 2. **Local dev.** Faster boot, no migration to run. Apps
|
|
10
|
+
* flip to `pgvector` for production via
|
|
11
|
+
* `config.rag.default`.
|
|
12
|
+
*
|
|
13
|
+
* Out of scope:
|
|
14
|
+
*
|
|
15
|
+
* - **Multitenancy.** No tenant scoping; everything in the
|
|
16
|
+
* same Map. Apps that test tenant isolation use pgvector
|
|
17
|
+
* against a real Postgres.
|
|
18
|
+
* - **Persistence.** Vectors die with the process.
|
|
19
|
+
* - **Performance.** O(N) scan per query — fine for thousands
|
|
20
|
+
* of vectors, painful past tens of thousands.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { CollectionNotFoundError } from '../rag_error.ts'
|
|
24
|
+
import type {
|
|
25
|
+
QueryOptions,
|
|
26
|
+
QueryResult,
|
|
27
|
+
VectorDocument,
|
|
28
|
+
VectorMatch,
|
|
29
|
+
} from '../types.ts'
|
|
1
30
|
import type { VectorStore } from '../vector_store.ts'
|
|
2
|
-
|
|
31
|
+
|
|
32
|
+
interface StoredDoc {
|
|
33
|
+
id: string
|
|
34
|
+
sourceId: string | null
|
|
35
|
+
content: string
|
|
36
|
+
embedding: readonly number[]
|
|
37
|
+
metadata: Record<string, unknown>
|
|
38
|
+
}
|
|
3
39
|
|
|
4
40
|
export class MemoryDriver implements VectorStore {
|
|
5
41
|
readonly name = 'memory'
|
|
6
|
-
private collections = new Map<string, VectorDocument[]>()
|
|
7
42
|
|
|
8
|
-
|
|
43
|
+
private readonly collections = new Map<string, Map<string, StoredDoc>>()
|
|
44
|
+
private readonly dimensions = new Map<string, number>()
|
|
45
|
+
|
|
46
|
+
async createCollection(collection: string, dimension: number): Promise<void> {
|
|
9
47
|
if (!this.collections.has(collection)) {
|
|
10
|
-
this.collections.set(collection,
|
|
48
|
+
this.collections.set(collection, new Map())
|
|
49
|
+
this.dimensions.set(collection, dimension)
|
|
11
50
|
}
|
|
12
51
|
}
|
|
13
52
|
|
|
14
53
|
async deleteCollection(collection: string): Promise<void> {
|
|
15
54
|
this.collections.delete(collection)
|
|
55
|
+
this.dimensions.delete(collection)
|
|
16
56
|
}
|
|
17
57
|
|
|
18
|
-
async upsert(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
58
|
+
async upsert(
|
|
59
|
+
collection: string,
|
|
60
|
+
documents: readonly VectorDocument[],
|
|
61
|
+
): Promise<void> {
|
|
62
|
+
const bucket = this.requireBucket(collection)
|
|
25
63
|
for (const doc of documents) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
docs.push(doc)
|
|
35
|
-
}
|
|
64
|
+
const id = doc.id ?? crypto.randomUUID()
|
|
65
|
+
bucket.set(id, {
|
|
66
|
+
id,
|
|
67
|
+
sourceId: doc.sourceId ?? null,
|
|
68
|
+
content: doc.content,
|
|
69
|
+
embedding: [...doc.embedding],
|
|
70
|
+
metadata: doc.metadata ?? {},
|
|
71
|
+
})
|
|
36
72
|
}
|
|
37
73
|
}
|
|
38
74
|
|
|
39
|
-
async delete(collection: string, ids:
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const idSet = new Set(ids.map(String))
|
|
44
|
-
this.collections.set(
|
|
45
|
-
collection,
|
|
46
|
-
docs.filter(d => !idSet.has(String(d.id)))
|
|
47
|
-
)
|
|
75
|
+
async delete(collection: string, ids: readonly string[]): Promise<void> {
|
|
76
|
+
const bucket = this.requireBucket(collection)
|
|
77
|
+
for (const id of ids) bucket.delete(id)
|
|
48
78
|
}
|
|
49
79
|
|
|
50
|
-
async deleteBySource(collection: string, sourceId: string
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
this.collections.set(
|
|
56
|
-
collection,
|
|
57
|
-
docs.filter(d => String(d.sourceId) !== sourceStr)
|
|
58
|
-
)
|
|
80
|
+
async deleteBySource(collection: string, sourceId: string): Promise<void> {
|
|
81
|
+
const bucket = this.requireBucket(collection)
|
|
82
|
+
for (const [id, doc] of bucket) {
|
|
83
|
+
if (doc.sourceId === sourceId) bucket.delete(id)
|
|
84
|
+
}
|
|
59
85
|
}
|
|
60
86
|
|
|
61
87
|
async flush(collection: string): Promise<void> {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
88
|
+
const bucket = this.collections.get(collection)
|
|
89
|
+
if (bucket) bucket.clear()
|
|
65
90
|
}
|
|
66
91
|
|
|
67
92
|
async query(
|
|
68
93
|
collection: string,
|
|
69
|
-
vector: number[],
|
|
70
|
-
options
|
|
94
|
+
vector: readonly number[],
|
|
95
|
+
options: QueryOptions = {},
|
|
71
96
|
): Promise<QueryResult> {
|
|
72
97
|
const start = performance.now()
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (threshold > 0) {
|
|
93
|
-
scored = scored.filter(m => m.score >= threshold)
|
|
98
|
+
const bucket = this.requireBucket(collection)
|
|
99
|
+
const topK = options.topK ?? 5
|
|
100
|
+
const threshold = options.threshold ?? 0
|
|
101
|
+
const filter = options.filter
|
|
102
|
+
|
|
103
|
+
const scored: VectorMatch[] = []
|
|
104
|
+
for (const doc of bucket.values()) {
|
|
105
|
+
if (filter && !matchesFilter(doc.metadata, filter)) continue
|
|
106
|
+
const score = cosineSimilarity(vector, doc.embedding)
|
|
107
|
+
if (score < threshold) continue
|
|
108
|
+
scored.push({
|
|
109
|
+
id: doc.id,
|
|
110
|
+
content: doc.content,
|
|
111
|
+
score,
|
|
112
|
+
metadata: doc.metadata,
|
|
113
|
+
sourceId: doc.sourceId,
|
|
114
|
+
})
|
|
94
115
|
}
|
|
95
116
|
|
|
96
117
|
scored.sort((a, b) => b.score - a.score)
|
|
97
118
|
const matches = scored.slice(0, topK)
|
|
98
|
-
|
|
99
|
-
return {
|
|
100
|
-
matches,
|
|
101
|
-
processingTimeMs: performance.now() - start,
|
|
102
|
-
}
|
|
119
|
+
return { matches, processingTimeMs: performance.now() - start }
|
|
103
120
|
}
|
|
104
121
|
|
|
105
|
-
|
|
106
|
-
|
|
122
|
+
private requireBucket(collection: string): Map<string, StoredDoc> {
|
|
123
|
+
const bucket = this.collections.get(collection)
|
|
124
|
+
if (!bucket) throw new CollectionNotFoundError(collection, this.name)
|
|
125
|
+
return bucket
|
|
107
126
|
}
|
|
108
127
|
}
|
|
109
128
|
|
|
110
|
-
|
|
129
|
+
/**
|
|
130
|
+
* Cosine similarity in [-1, 1] mapped to [0, 1] by `(s + 1) / 2`.
|
|
131
|
+
* Matches pgvector's `1 - (a <=> b)` semantic so MemoryDriver and
|
|
132
|
+
* PgvectorDriver scores compare like-for-like.
|
|
133
|
+
*/
|
|
134
|
+
function cosineSimilarity(a: readonly number[], b: readonly number[]): number {
|
|
135
|
+
const len = Math.min(a.length, b.length)
|
|
111
136
|
let dot = 0
|
|
112
|
-
let
|
|
113
|
-
let
|
|
114
|
-
|
|
115
|
-
for (let i = 0; i < a.length; i++) {
|
|
137
|
+
let normA = 0
|
|
138
|
+
let normB = 0
|
|
139
|
+
for (let i = 0; i < len; i++) {
|
|
116
140
|
const ai = a[i]!
|
|
117
141
|
const bi = b[i]!
|
|
118
142
|
dot += ai * bi
|
|
119
|
-
|
|
120
|
-
|
|
143
|
+
normA += ai * ai
|
|
144
|
+
normB += bi * bi
|
|
121
145
|
}
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
return
|
|
146
|
+
if (normA === 0 || normB === 0) return 0
|
|
147
|
+
const cos = dot / (Math.sqrt(normA) * Math.sqrt(normB))
|
|
148
|
+
return (cos + 1) / 2
|
|
125
149
|
}
|
|
126
150
|
|
|
151
|
+
/** Flat AND match — every key in `filter` must equal the corresponding `metadata` key. */
|
|
127
152
|
function matchesFilter(
|
|
128
153
|
metadata: Record<string, unknown>,
|
|
129
|
-
filter: Record<string, unknown
|
|
154
|
+
filter: Record<string, unknown>,
|
|
130
155
|
): boolean {
|
|
131
|
-
for (const
|
|
132
|
-
if (metadata[key] !==
|
|
156
|
+
for (const key of Object.keys(filter)) {
|
|
157
|
+
if (metadata[key] !== filter[key]) return false
|
|
133
158
|
}
|
|
134
159
|
return true
|
|
135
160
|
}
|