@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 CHANGED
@@ -1,33 +1,31 @@
1
1
  {
2
2
  "name": "@strav/rag",
3
- "version": "0.4.31",
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
- "description": "Vector retrieval framework for RAG in the Strav framework",
6
- "license": "MIT",
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
- "stubs/",
17
- "package.json",
18
- "tsconfig.json"
12
+ "src",
13
+ "README.md"
19
14
  ],
20
- "peerDependencies": {
21
- "@strav/kernel": "0.4.31",
22
- "@strav/brain": "0.4.31",
23
- "@strav/database": "0.4.31",
24
- "@strav/cli": "0.4.31"
15
+ "engines": {
16
+ "bun": ">=1.3.14"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
25
20
  },
26
- "scripts": {
27
- "test": "bun test tests/",
28
- "typecheck": "tsc --noEmit"
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
  }
@@ -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 chunks: Chunk[] = []
13
- const step = Math.max(1, this.chunkSize - this.overlap)
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
- chunks.push({
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
- const result: string[] = []
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
- result.push(text.slice(i, i + this.chunkSize))
64
+ out.push(text.slice(i, i + this.chunkSize))
30
65
  }
31
- return result
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
- private buildChunks(original: string, pieces: string[]): Chunk[] {
59
- const chunks: Chunk[] = []
60
- let searchFrom = 0
61
-
62
- for (let i = 0; i < pieces.length; i++) {
63
- const piece = pieces[i]!
64
- const foundAt = original.indexOf(piece, searchFrom)
65
- const startOffset = foundAt >= 0 ? foundAt : searchFrom
66
- const pieceEnd = startOffset + piece.length
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
- const overlapEnd = Math.min(pieceEnd + this.overlap, original.length)
69
- const chunkContent = original.slice(startOffset, overlapEnd)
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
- chunks.push({
72
- content: chunkContent,
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: overlapEnd,
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,3 @@
1
+ export { RagConsoleProvider } from './rag_console_provider.ts'
2
+ export { RagFlush } from './rag_flush.ts'
3
+ export { RagList } from './rag_list.ts'
@@ -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
- import type { VectorDocument, QueryOptions, QueryResult, VectorMatch } from '../types.ts'
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
- async createCollection(collection: string, _dimension: number): Promise<void> {
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(collection: string, documents: VectorDocument[]): Promise<void> {
19
- let docs = this.collections.get(collection)
20
- if (!docs) {
21
- docs = []
22
- this.collections.set(collection, docs)
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
- if (doc.id != null) {
27
- const existingIndex = docs.findIndex(d => d.id === doc.id)
28
- if (existingIndex >= 0) {
29
- docs[existingIndex] = doc
30
- } else {
31
- docs.push(doc)
32
- }
33
- } else {
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: (string | number)[]): Promise<void> {
40
- const docs = this.collections.get(collection)
41
- if (!docs) return
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 | number): Promise<void> {
51
- const docs = this.collections.get(collection)
52
- if (!docs) return
53
-
54
- const sourceStr = String(sourceId)
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
- if (this.collections.has(collection)) {
63
- this.collections.set(collection, [])
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?: QueryOptions
94
+ vector: readonly number[],
95
+ options: QueryOptions = {},
71
96
  ): Promise<QueryResult> {
72
97
  const start = performance.now()
73
- const docs = this.collections.get(collection)
74
- if (!docs || docs.length === 0) {
75
- return { matches: [], processingTimeMs: performance.now() - start }
76
- }
77
-
78
- const topK = options?.topK ?? 5
79
- const threshold = options?.threshold ?? 0
80
-
81
- let scored: VectorMatch[] = docs.map(doc => ({
82
- id: doc.id ?? 0,
83
- content: doc.content,
84
- score: cosineSimilarity(vector, doc.embedding),
85
- metadata: doc.metadata ?? {},
86
- }))
87
-
88
- if (options?.filter) {
89
- scored = scored.filter(m => matchesFilter(m.metadata, options.filter!))
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
- getCollection(collection: string): VectorDocument[] {
106
- return this.collections.get(collection) ?? []
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
- function cosineSimilarity(a: number[], b: number[]): number {
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 magA = 0
113
- let magB = 0
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
- magA += ai * ai
120
- magB += bi * bi
143
+ normA += ai * ai
144
+ normB += bi * bi
121
145
  }
122
-
123
- const denom = Math.sqrt(magA) * Math.sqrt(magB)
124
- return denom === 0 ? 0 : dot / denom
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 [key, value] of Object.entries(filter)) {
132
- if (metadata[key] !== value) return false
156
+ for (const key of Object.keys(filter)) {
157
+ if (metadata[key] !== filter[key]) return false
133
158
  }
134
159
  return true
135
160
  }