@strav/rag 0.4.31 → 1.0.0-alpha.19
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 +20 -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/drivers/memory_driver.ts +110 -85
- package/src/drivers/pgvector_driver.ts +174 -109
- package/src/index.ts +40 -36
- package/src/migrations.ts +116 -0
- package/src/rag_error.ts +76 -0
- package/src/rag_manager.ts +290 -66
- package/src/rag_provider.ts +85 -7
- package/src/rag_vector_schema.ts +56 -0
- 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/src/retrievable.ts +0 -179
- package/stubs/config/rag.ts +0 -33
- package/tsconfig.json +0 -5
package/package.json
CHANGED
|
@@ -1,33 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/rag",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0-alpha.19",
|
|
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.19",
|
|
23
|
+
"@strav/database": "1.0.0-alpha.19",
|
|
24
|
+
"@strav/kernel": "1.0.0-alpha.19"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@types/bun": ">=1.3.14"
|
|
29
28
|
},
|
|
30
|
-
"devDependencies":
|
|
31
|
-
"commander": "^14.0.3"
|
|
32
|
-
}
|
|
29
|
+
"devDependencies": null
|
|
33
30
|
}
|
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
|
}
|
|
@@ -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
|
}
|