pikuri-vectordb 0.0.7 → 0.1.0
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.
- checksums.yaml +4 -4
- data/README.md +5 -5
- data/lib/pikuri/vector_db/backend/chroma.rb +76 -177
- data/lib/pikuri/vector_db/backend/in_memory.rb +40 -82
- data/lib/pikuri/vector_db/backend/qdrant.rb +50 -131
- data/lib/pikuri/vector_db/backend/result.rb +12 -35
- data/lib/pikuri/vector_db/backend.rb +33 -61
- data/lib/pikuri/vector_db/chunk.rb +24 -55
- data/lib/pikuri/vector_db/chunker/fixed_window.rb +16 -41
- data/lib/pikuri/vector_db/chunker.rb +12 -36
- data/lib/pikuri/vector_db/embedder.rb +10 -38
- data/lib/pikuri/vector_db/extension.rb +32 -75
- data/lib/pikuri/vector_db/indexer.rb +88 -175
- data/lib/pikuri/vector_db/librarian.rb +20 -53
- data/lib/pikuri/vector_db/reranker/hit.rb +8 -25
- data/lib/pikuri/vector_db/reranker/llama_server.rb +15 -59
- data/lib/pikuri/vector_db/reranker.rb +16 -41
- data/lib/pikuri/vector_db/server/chroma.rb +19 -47
- data/lib/pikuri/vector_db/server/docker_container.rb +35 -74
- data/lib/pikuri/vector_db/server/in_memory.rb +15 -33
- data/lib/pikuri/vector_db/server/qdrant.rb +24 -50
- data/lib/pikuri/vector_db/server.rb +15 -25
- data/lib/pikuri/vector_db/tokenizer/char_heuristic.rb +9 -28
- data/lib/pikuri/vector_db/tokenizer/llama_server.rb +13 -37
- data/lib/pikuri/vector_db/tokenizer.rb +9 -46
- data/lib/pikuri/vector_db/tools/read.rb +39 -61
- data/lib/pikuri/vector_db/tools/reindex.rb +24 -38
- data/lib/pikuri/vector_db/tools/search.rb +56 -88
- data/lib/pikuri/vector_db/watcher.rb +49 -83
- data/lib/pikuri-vectordb.rb +19 -75
- data/prompts/pikuri-corpus.txt +1 -17
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 041cfbc12058abab95bdf6a38340c69096b49fc14b7c779aa8f30c2cece76153
|
|
4
|
+
data.tar.gz: 23e98ff1fcbdfae0fab697c632ec71cbb6a51ea6b28e32fbb80999b277cbd68a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3bdba1b9a8eae36a3c70f5a25eec021d35b476b8f9463b9e1a1c25a099f5905ac6edf2cade908b866ce6ec5b0dbed824c86030edba9363a7e96abe6f62d0639b
|
|
7
|
+
data.tar.gz: 16c1a6b59c540007c2386cd5df6b679f38af6bc7a3b3e31f72ad6e35a19a2a9fa7ce948eb3b7ede0f0005ad58e0a30ae8bea70a2e6fd8f12cfa8225eab3094ef
|
data/README.md
CHANGED
|
@@ -119,14 +119,14 @@ From a source checkout (not installed by `gem install`):
|
|
|
119
119
|
./pikuri-vectordb/bin/pikuri-corpus --qdrant --watch
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
-
A single recall agent over `
|
|
122
|
+
A single recall agent over `book/` (the pikuri guide itself)
|
|
123
123
|
with **no egress** — its tools are the three above plus
|
|
124
124
|
`calculator`; no web search, no fetch, no bash. The corpus stands
|
|
125
125
|
in for private data, and an agent that can read it must not also be
|
|
126
126
|
able to send it out. `--qdrant` / `--chroma` persist the index
|
|
127
127
|
across runs, `--watch` keeps it live, `--no-reranker` drops the
|
|
128
128
|
reranker requirement. The guide's
|
|
129
|
-
[chapter
|
|
129
|
+
[the vector-DB chapter](../book/vectordb.md) is the full walkthrough.
|
|
130
130
|
|
|
131
131
|
## The LIBRARIAN persona
|
|
132
132
|
|
|
@@ -157,8 +157,8 @@ whichever model each request asks for. Requires a recent enough
|
|
|
157
157
|
`llama.cpp` build to include the
|
|
158
158
|
[model-management feature](https://huggingface.co/blog/ggml-org/model-management-in-llamacpp);
|
|
159
159
|
Ubuntu 26.04+ packages one. The guide's
|
|
160
|
-
[chapter
|
|
161
|
-
[chapter
|
|
160
|
+
[the first-conversation chapter](../book/chat.md) walks through the setup;
|
|
161
|
+
[the vector-DB chapter](../book/vectordb.md) adds the embedder and
|
|
162
162
|
reranker on top.
|
|
163
163
|
|
|
164
164
|
If you'd rather pin the reranker in its own process — to avoid
|
|
@@ -186,7 +186,7 @@ events for the `Watcher`; loaded only when a `Watcher` starts).
|
|
|
186
186
|
## Further reading
|
|
187
187
|
|
|
188
188
|
- **Guide chapter:** [Agentic search and the vector
|
|
189
|
-
DB](../
|
|
189
|
+
DB](../book/vectordb.md) — concepts, model setup, the
|
|
190
190
|
no-egress argument, `--qdrant --watch` day-to-day shape.
|
|
191
191
|
- **Design notes:** [`DESIGN.md`](DESIGN.md) — the Chroma-vs-Qdrant
|
|
192
192
|
engine survey.
|
|
@@ -6,147 +6,67 @@ require 'json'
|
|
|
6
6
|
module Pikuri
|
|
7
7
|
module VectorDb
|
|
8
8
|
module Backend
|
|
9
|
-
# Thin Faraday HTTP client against a self-hosted Chroma
|
|
10
|
-
#
|
|
11
|
-
# duck-typed {Backend} protocol as {InMemory}
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
9
|
+
# Thin Faraday HTTP client against a self-hosted Chroma server (v2 API),
|
|
10
|
+
# targeting Chroma 0.5.x+. The persistent backend, behind the same
|
|
11
|
+
# duck-typed {Backend} protocol as {InMemory} (same method names, return
|
|
12
|
+
# shapes, and +ArgumentError+ contract on empty input / non-positive
|
|
13
|
+
# +top_k+; the vector-dim contract diverges — see below). Hand-rolled
|
|
14
|
+
# rather than a +chroma-db+ gem dep so the wire protocol stays auditable
|
|
15
|
+
# in one readable file; the cost is tracking the v2 API by hand.
|
|
15
16
|
#
|
|
16
|
-
#
|
|
17
|
-
# +
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
# v2 API by hand if it changes.
|
|
17
|
+
# Two ways to get one: **bring your own** — +Chroma.new(host:, port:,
|
|
18
|
+
# collection:)+ against an existing deployment (this class is purely the
|
|
19
|
+
# HTTP client); or **let pikuri manage it** —
|
|
20
|
+
# {Server::Chroma.ensure_running}'s +#client(collection:)+ returns a
|
|
21
|
+
# +Chroma+ pointed at a supervised container. Docker lifecycle and HTTP
|
|
22
|
+
# wire protocol share nothing, so each is its own class.
|
|
23
23
|
#
|
|
24
|
-
#
|
|
24
|
+
# v2 endpoints used: +collections+ (get-or-create), +.../upsert+,
|
|
25
|
+
# +.../query+, +.../count+, +DELETE .../{id}+, +.../delete+ (where-filtered),
|
|
26
|
+
# +.../get+ (where-filtered projection).
|
|
25
27
|
#
|
|
26
|
-
#
|
|
27
|
-
# collection:)+ against an existing chroma deployment
|
|
28
|
-
# (production cluster, docker-compose stack, a chroma
|
|
29
|
-
# already running on the host for an unrelated project).
|
|
30
|
-
# The host owns the process; this class is purely the
|
|
31
|
-
# HTTP client.
|
|
32
|
-
# * **Let pikuri manage it.** {Server::Chroma.ensure_running}
|
|
33
|
-
# spawns and supervises a chroma container under the
|
|
34
|
-
# +pikuri-internal-chroma+ name, against a pinned image,
|
|
35
|
-
# with a bind-mounted volume in the user's cache dir.
|
|
36
|
-
# Its +#client(collection:)+ returns a +Backend::Chroma+
|
|
37
|
-
# pre-pointed at the supervised container. The split is
|
|
38
|
-
# deliberate: docker lifecycle and HTTP wire protocol
|
|
39
|
-
# have nothing in common, so each lives in its own class.
|
|
28
|
+
# == BYO embeddings (Chroma's embedder is never invoked)
|
|
40
29
|
#
|
|
41
|
-
#
|
|
30
|
+
# Chroma collections can carry a server-side embedding function; we always
|
|
31
|
+
# send pre-computed +embeddings+ and never use it. pikuri's {Embedder} is
|
|
32
|
+
# the single source of truth — a parallel Chroma-side embedder would split
|
|
33
|
+
# it invisibly (local embedder in pikuri + +OpenAIEmbeddingFunction+ in
|
|
34
|
+
# Chroma ⇒ every indexed document silently lands at OpenAI).
|
|
42
35
|
#
|
|
43
|
-
#
|
|
36
|
+
# == Contracts
|
|
44
37
|
#
|
|
45
|
-
# *
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
# +
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
# *
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
# +{where:}+ filter with an +include:+ projection; used by
|
|
61
|
-
# +#sources_with_hashes+.
|
|
62
|
-
#
|
|
63
|
-
# == BYO embeddings (not Chroma's embedder)
|
|
64
|
-
#
|
|
65
|
-
# Chroma collections can carry an embedding function in
|
|
66
|
-
# their metadata — Chroma's term for what pikuri calls an
|
|
67
|
-
# {Embedder}. When configured, +add+ / +query+ accept raw
|
|
68
|
-
# text via +documents+ / +query_texts+ and Chroma embeds
|
|
69
|
-
# server-side. We deliberately don't use this: pikuri's
|
|
70
|
-
# +Embedder+ is the one source of truth for embedder
|
|
71
|
-
# choice, the provider-cliff visibility lives in pikuri's
|
|
72
|
-
# config, and a parallel Chroma-side embedder config would
|
|
73
|
-
# split the truth without pikuri noticing (e.g. local
|
|
74
|
-
# embedder in pikuri + +OpenAIEmbeddingFunction+ in Chroma
|
|
75
|
-
# — every indexed document silently lands at OpenAI). We
|
|
76
|
-
# always send pre-computed +embeddings+; Chroma's
|
|
77
|
-
# collection embedder is never invoked.
|
|
78
|
-
#
|
|
79
|
-
# == Vector-dim contract diverges from InMemory
|
|
80
|
-
#
|
|
81
|
-
# +InMemory+ enforces vector-dim consistency client-side
|
|
82
|
-
# (locks on first upsert, raises +ArgumentError+ on
|
|
83
|
-
# mismatch). +Chroma+ enforces server-side — first upsert
|
|
84
|
-
# to a collection establishes the dim; mismatched
|
|
85
|
-
# subsequent upserts produce HTTP 4xx which propagates
|
|
86
|
-
# as +RuntimeError+. Different exception class, same
|
|
87
|
-
# loud-failure shape. Documented divergence; not worth
|
|
88
|
-
# parsing Chroma's error envelope to coerce to +ArgumentError+.
|
|
89
|
-
#
|
|
90
|
-
# == Lazy collection resolution
|
|
91
|
-
#
|
|
92
|
-
# +Backend::Chroma.new+ doesn't talk to the server. The
|
|
93
|
-
# first +#upsert+ / +#query+ / +#count+ call resolves
|
|
94
|
-
# (and creates if missing) the collection by name, caches
|
|
95
|
-
# the id, and uses it thereafter. +#delete_all+ drops the
|
|
96
|
-
# collection and clears the cached id; the next +#upsert+
|
|
97
|
-
# re-creates from scratch.
|
|
98
|
-
#
|
|
99
|
-
# == Cosine distance (matches InMemory)
|
|
100
|
-
#
|
|
101
|
-
# Collection is created with +hnsw.space: 'cosine'+.
|
|
102
|
-
# Chroma returns cosine *distance* (range +[0, 2]+ where
|
|
103
|
-
# +0+ = identical, +1+ = orthogonal); +#query+ converts
|
|
104
|
-
# to similarity via +1 - distance+ so the {Backend::Result}
|
|
105
|
-
# score has the same meaning across backends.
|
|
106
|
-
#
|
|
107
|
-
# == Metadata key normalization
|
|
108
|
-
#
|
|
109
|
-
# Chroma serializes through JSON, so Symbol metadata keys
|
|
110
|
-
# become Strings on round-trip. +#upsert+ converts the
|
|
111
|
-
# incoming {Chunk}'s +metadata+ keys to Strings before
|
|
112
|
-
# sending; +#query+ converts them back to Symbols on the
|
|
113
|
-
# way out, so the {Chunk} a caller pulls from a query
|
|
114
|
-
# looks identical to one stored in InMemory. +source+
|
|
115
|
-
# rides as a special metadata key (Chroma has no native
|
|
116
|
-
# +source+ concept).
|
|
117
|
-
#
|
|
118
|
-
# == Testing posture
|
|
119
|
-
#
|
|
120
|
-
# Specs use +Faraday::Adapter::Test+ stubs only — they
|
|
121
|
-
# verify "we send what we think we're sending" against
|
|
122
|
-
# the v2 API shape but don't catch real-Chroma protocol
|
|
123
|
-
# drift. Real-Chroma smoke testing is wired into the demo
|
|
124
|
-
# binary in a later phase. Targets Chroma 0.5.x+ (v2 API).
|
|
38
|
+
# * *Vector-dim* diverges from {InMemory}: enforced server-side (first
|
|
39
|
+
# upsert sets the dim; a mismatch is HTTP 4xx → +RuntimeError+, not the
|
|
40
|
+
# +ArgumentError+ InMemory raises). Same loud-failure shape; not worth
|
|
41
|
+
# parsing Chroma's error envelope to coerce the class.
|
|
42
|
+
# * *Lazy collection resolution*: +new+ doesn't touch the server; the first
|
|
43
|
+
# +#upsert+/+#query+/+#count+ resolves (creating if missing) the
|
|
44
|
+
# collection by name and caches the id. +#delete_all+ clears it; the next
|
|
45
|
+
# +#upsert+ re-creates.
|
|
46
|
+
# * *Cosine*: collection created +hnsw.space: 'cosine'+; Chroma returns
|
|
47
|
+
# *distance* +[0,2]+, +#query+ converts via +1 - distance+ so the
|
|
48
|
+
# {Backend::Result} score matches InMemory's similarity scale.
|
|
49
|
+
# * *Metadata keys*: JSON round-trips Symbol keys to Strings, so +#upsert+
|
|
50
|
+
# stringifies and +#query+ re-symbolizes — a queried {Chunk} looks
|
|
51
|
+
# identical to one stored in InMemory. +source+ rides as a reserved
|
|
52
|
+
# metadata key (Chroma has no native +source+).
|
|
125
53
|
class Chroma
|
|
126
|
-
# Rows per +/get+ page in {#sources_with_hashes}
|
|
127
|
-
#
|
|
128
|
-
# a
|
|
129
|
-
#
|
|
130
|
-
# manifest is one row per file (the +offset 0+ chunk), so a
|
|
131
|
-
# 50k-file corpus is ~50 localhost round trips instead of one
|
|
132
|
-
# multi-MB response.
|
|
54
|
+
# Rows per +/get+ page in {#sources_with_hashes} — caps the parse
|
|
55
|
+
# working set of the boot manifest on a large corpus (one row per file,
|
|
56
|
+
# so a 50k-file corpus is ~50 localhost round trips, not one multi-MB
|
|
57
|
+
# response). Small corpora finish in one page.
|
|
133
58
|
MANIFEST_PAGE_SIZE = 1_000
|
|
134
59
|
|
|
135
60
|
# @param host [String]
|
|
136
61
|
# @param port [Integer]
|
|
137
|
-
# @param collection [String] collection name
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
# @param
|
|
142
|
-
#
|
|
143
|
-
# @param database [String] Chroma v2 database; defaults
|
|
144
|
-
# to Chroma's own default.
|
|
145
|
-
# @param connection [Faraday::Connection, nil] optional
|
|
146
|
-
# dependency-injection point for tests.
|
|
62
|
+
# @param collection [String] Chroma collection name — a Chroma-specific
|
|
63
|
+
# identifier, so it lives here, not on +VectorDb::Extension+ (a no-op
|
|
64
|
+
# for {InMemory}).
|
|
65
|
+
# @param tenant [String] Chroma v2 tenant; defaults to Chroma's default.
|
|
66
|
+
# @param database [String] Chroma v2 database; defaults to Chroma's default.
|
|
67
|
+
# @param connection [Faraday::Connection, nil] DI point for tests.
|
|
147
68
|
# @return [Chroma]
|
|
148
|
-
# @raise [ArgumentError] on empty +host+ or empty
|
|
149
|
-
# +collection+.
|
|
69
|
+
# @raise [ArgumentError] on empty +host+ or empty +collection+.
|
|
150
70
|
def initialize(host:, port:, collection:,
|
|
151
71
|
tenant: 'default_tenant',
|
|
152
72
|
database: 'default_database',
|
|
@@ -167,13 +87,9 @@ module Pikuri
|
|
|
167
87
|
end
|
|
168
88
|
end
|
|
169
89
|
|
|
170
|
-
# Insert-or-replace by +chunk.id
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
-
# vector-dim consistency; mismatched dims surface as
|
|
174
|
-
# +RuntimeError+ from a 4xx response (the InMemory
|
|
175
|
-
# backend raises +ArgumentError+ for the same case —
|
|
176
|
-
# documented divergence).
|
|
90
|
+
# Insert-or-replace by +chunk.id+ (parallel equal-length arrays).
|
|
91
|
+
# Vector-dim mismatch surfaces as +RuntimeError+ server-side (InMemory
|
|
92
|
+
# raises +ArgumentError+ — see the class header).
|
|
177
93
|
#
|
|
178
94
|
# @param chunks [Array<Chunk>]
|
|
179
95
|
# @param vectors [Array<Array<Float>>]
|
|
@@ -254,12 +170,9 @@ module Pikuri
|
|
|
254
170
|
end
|
|
255
171
|
end
|
|
256
172
|
|
|
257
|
-
# Drop the collection
|
|
258
|
-
#
|
|
259
|
-
# the
|
|
260
|
-
# created (consistent with {InMemory}'s clear-on-empty
|
|
261
|
-
# behaviour). 404 on the DELETE is treated as "already
|
|
262
|
-
# gone" — idempotent.
|
|
173
|
+
# Drop the collection (the nuke-and-reload reindex path the {Indexer}
|
|
174
|
+
# drives); the next +#upsert+ re-creates. No-op if none was created; a
|
|
175
|
+
# 404 on the DELETE is treated as "already gone" — idempotent.
|
|
263
176
|
#
|
|
264
177
|
# @return [void]
|
|
265
178
|
def delete_all
|
|
@@ -309,22 +222,16 @@ module Pikuri
|
|
|
309
222
|
nil
|
|
310
223
|
end
|
|
311
224
|
|
|
312
|
-
# Replace all chunks for one +source+: delete the old set,
|
|
313
|
-
#
|
|
314
|
-
# (see {Indexer#reindex_file!}).
|
|
315
|
-
#
|
|
316
|
-
# == Not transactional (the InMemory divergence)
|
|
225
|
+
# Replace all chunks for one +source+: delete the old set, then upsert
|
|
226
|
+
# the new (the incremental-reindex unit, {Indexer#reindex_file!}).
|
|
317
227
|
#
|
|
318
|
-
#
|
|
319
|
-
# them can see the source with zero chunks — a window
|
|
320
|
-
# {InMemory#replace_source} closes with its monitor but
|
|
321
|
-
#
|
|
322
|
-
#
|
|
323
|
-
#
|
|
324
|
-
#
|
|
325
|
-
# this method and the old chunks stay put. Delete-then-upsert
|
|
326
|
-
# (not the reverse): upserting first then deleting by source
|
|
327
|
-
# would delete the just-written chunks.
|
|
228
|
+
# *Not transactional* (the InMemory divergence): two HTTP calls, so a
|
|
229
|
+
# +#query+ between them can see the source with zero chunks — a window
|
|
230
|
+
# {InMemory#replace_source} closes with its monitor but Chroma can't. The
|
|
231
|
+
# {Indexer} mitigates the common failure by embedding *before* calling
|
|
232
|
+
# here, so an embedder outage never reaches this and the old chunks stay.
|
|
233
|
+
# Delete-then-upsert, not the reverse — the reverse would delete the
|
|
234
|
+
# just-written chunks.
|
|
328
235
|
#
|
|
329
236
|
# @param source [String] the {Chunk#source} being replaced.
|
|
330
237
|
# @param chunks [Array<Chunk>] the new chunk set.
|
|
@@ -338,28 +245,20 @@ module Pikuri
|
|
|
338
245
|
nil
|
|
339
246
|
end
|
|
340
247
|
|
|
341
|
-
# The boot-sweep reference: +source+ → stored content hash
|
|
342
|
-
#
|
|
343
|
-
#
|
|
344
|
-
#
|
|
345
|
-
#
|
|
346
|
-
#
|
|
347
|
-
#
|
|
348
|
-
# and +documents+ from the response; we pull only the
|
|
349
|
-
# metadata projection, never the vectors.
|
|
350
|
-
# * +limit+ / +offset+ — page the read in
|
|
351
|
-
# {MANIFEST_PAGE_SIZE} chunks so a large corpus never
|
|
352
|
-
# materializes one multi-MB response. (Two unrelated
|
|
353
|
-
# +offset+s collide in the wording: the +where+ +offset+ is
|
|
354
|
-
# a *chunk* metadata field; the top-level +offset+ is the
|
|
355
|
-
# *pagination* cursor — different namespaces in the API.)
|
|
248
|
+
# The boot-sweep reference: +source+ → stored content hash, one metadata
|
|
249
|
+
# row per *file* (not per chunk) via +where: { offset: 0 }+ (every file
|
|
250
|
+
# has one chunk at offset 0), +include: ['metadatas']+ (drops the heavy
|
|
251
|
+
# embeddings/documents), and {MANIFEST_PAGE_SIZE} +limit+/+offset+
|
|
252
|
+
# paging so a large corpus never materializes one multi-MB response.
|
|
253
|
+
# (Two +offset+s collide in wording: the +where+ one is a *chunk*
|
|
254
|
+
# metadata field, the top-level one is the *pagination* cursor.)
|
|
356
255
|
#
|
|
357
|
-
# Pagination assumes the manifest isn't mutating mid-read; the
|
|
358
|
-
#
|
|
359
|
-
#
|
|
256
|
+
# Pagination assumes the manifest isn't mutating mid-read; the {Watcher}
|
|
257
|
+
# drives this from its single worker thread, so no reindex runs
|
|
258
|
+
# concurrently.
|
|
360
259
|
#
|
|
361
|
-
# @return [Hash{String => String, nil}] +source+ → content
|
|
362
|
-
#
|
|
260
|
+
# @return [Hash{String => String, nil}] +source+ → content hash. Empty
|
|
261
|
+
# when the collection doesn't exist yet.
|
|
363
262
|
# @raise [RuntimeError] on HTTP failure.
|
|
364
263
|
def sources_with_hashes
|
|
365
264
|
return {} if @collection_id.nil? && !collection_exists?
|
|
@@ -5,72 +5,40 @@ require 'monitor'
|
|
|
5
5
|
module Pikuri
|
|
6
6
|
module VectorDb
|
|
7
7
|
module Backend
|
|
8
|
-
# Pure-Ruby vector store
|
|
9
|
-
#
|
|
10
|
-
#
|
|
8
|
+
# Pure-Ruby vector store — the educational default backend, the "small
|
|
9
|
+
# enough to audit" first stop before users promote to {Qdrant} / {Chroma}
|
|
10
|
+
# for persistence. Holds a Hash from chunk id to +[Chunk, vector]+;
|
|
11
|
+
# +#query+ scores cosine similarity against every stored vector, O(n) per
|
|
12
|
+
# query — fine for thousands of chunks (a notes folder), slow for millions.
|
|
11
13
|
#
|
|
12
|
-
#
|
|
14
|
+
# Deliberately no persistence (RAM-only; reloads from sources every boot,
|
|
15
|
+
# which is *why* it's the teaching shape — the boot code path is the one a
|
|
16
|
+
# newcomer inspects to learn what "indexing" means) and no approximate-search
|
|
17
|
+
# index (exhaustive scan; HNSW/IVF adds complexity that teaches nothing once
|
|
18
|
+
# the cosine math is clear).
|
|
13
19
|
#
|
|
14
|
-
#
|
|
15
|
-
# +#query+ computes cosine similarity against every stored
|
|
16
|
-
# vector, sorts descending, returns the top-k as
|
|
17
|
-
# +Backend::Result+ instances. O(n) per query, where n is
|
|
18
|
-
# the number of stored chunks. Fine for thousands of chunks
|
|
19
|
-
# (a personal notes folder, a single product's docs); slow
|
|
20
|
-
# for millions (a full corporate knowledge base — that's the
|
|
21
|
-
# +Chroma+ use case).
|
|
20
|
+
# == Thread-safe (the one backend that locks)
|
|
22
21
|
#
|
|
23
|
-
#
|
|
22
|
+
# Every method runs under a reentrant +Monitor+ — real concurrency once
|
|
23
|
+
# auto-watch is wired (the main thread queries while the {Watcher} thread
|
|
24
|
+
# replaces/deletes). The lock's load-bearing job is {#replace_source}: it
|
|
25
|
+
# holds the monitor across the delete-then-upsert so a concurrent +#query+
|
|
26
|
+
# never sees the zero-chunk gap. +Monitor+ not +Mutex+ because
|
|
27
|
+
# +#replace_source+ re-enters via +#delete_by_source+ + +#upsert+. Chroma
|
|
28
|
+
# / Qdrant need no client-side lock — the server serializes.
|
|
24
29
|
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
# teaching shape: the same code path the demo binary walks
|
|
29
|
-
# on startup is the one the user inspects when they're
|
|
30
|
-
# learning what "indexing" actually means.
|
|
31
|
-
# * **No approximate search.** Exhaustive scan. Approximate
|
|
32
|
-
# nearest neighbor (HNSW, IVF) adds complexity that doesn't
|
|
33
|
-
# teach anything additional once the cosine math is clear.
|
|
34
|
-
# * **No approximate-search index.** Exhaustive scan only.
|
|
35
|
-
#
|
|
36
|
-
# == Thread safety
|
|
37
|
-
#
|
|
38
|
-
# Every public method runs under a single reentrant
|
|
39
|
-
# +Monitor+. The agent's main thread calls +#query+ while a
|
|
40
|
-
# background {Watcher} thread calls +#replace_source+ /
|
|
41
|
-
# +#delete_by_source+, so concurrent access is real once
|
|
42
|
-
# auto-watch is wired. The lock's load-bearing job is
|
|
43
|
-
# {#replace_source}: it holds the monitor across the
|
|
44
|
-
# delete-then-upsert so a concurrent +#query+ never observes
|
|
45
|
-
# the gap where a source has zero chunks. +Monitor+ (not a
|
|
46
|
-
# bare +Mutex+) because +#replace_source+ re-enters the lock
|
|
47
|
-
# via +#delete_by_source+ + +#upsert+, which a non-reentrant
|
|
48
|
-
# +Mutex+ would deadlock on. +Chroma+ needs no client-side
|
|
49
|
-
# lock — the server serializes — so this is the one backend
|
|
50
|
-
# that locks.
|
|
51
|
-
#
|
|
52
|
-
# == Cosine, not dot product
|
|
53
|
-
#
|
|
54
|
-
# Some embedders return pre-normalized vectors (text-embedding-3,
|
|
55
|
-
# most sentence-transformers); others don't. Cosine normalizes
|
|
56
|
-
# at compute time, so the backend works regardless of whether
|
|
57
|
-
# the embedder did. The readable two-pass form below (compute
|
|
58
|
-
# dot + magnitudes separately) is intentional over the
|
|
59
|
-
# single-loop micro-optimization — this is the file the
|
|
60
|
-
# newcomer reads to understand what's happening.
|
|
30
|
+
# Cosine (not dot product) so the backend works whether or not the embedder
|
|
31
|
+
# pre-normalizes; the readable two-pass {#cosine} is intentional over a
|
|
32
|
+
# single-loop micro-opt — this is the file the newcomer reads.
|
|
61
33
|
class InMemory
|
|
62
34
|
# @return [InMemory]
|
|
63
35
|
def initialize
|
|
64
36
|
# id (String) → [Chunk, vector (Array<Float>)]
|
|
65
37
|
@entries = {}
|
|
66
|
-
#
|
|
67
|
-
# +#upsert
|
|
68
|
-
# enforced for every subsequent +#upsert+ + +#query+ — see
|
|
69
|
-
# the Backend protocol's "Vector-dim contract" yardoc.
|
|
38
|
+
# Vector dim; +nil+ until the first +#upsert+ locks it, then enforced
|
|
39
|
+
# on every +#upsert+/+#query+ — see the Backend "Vector-dim contract".
|
|
70
40
|
@dim = nil
|
|
71
|
-
# Reentrant
|
|
72
|
-
# + +#upsert+ while holding the lock — see the class yardoc's
|
|
73
|
-
# "Thread safety" section.
|
|
41
|
+
# Reentrant — see the class header's "Thread-safe" section.
|
|
74
42
|
@lock = Monitor.new
|
|
75
43
|
end
|
|
76
44
|
|
|
@@ -167,20 +135,17 @@ module Pikuri
|
|
|
167
135
|
nil
|
|
168
136
|
end
|
|
169
137
|
|
|
170
|
-
# Atomically replace all chunks for one +source
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
# concurrent {#query} sees either the old chunks or the new
|
|
175
|
-
# ones, never the empty gap between.
|
|
138
|
+
# Atomically replace all chunks for one +source+ (delete-then-upsert
|
|
139
|
+
# under a single monitor hold — the point: a concurrent {#query} sees
|
|
140
|
+
# the old or new chunks, never the empty gap). The incremental-reindex
|
|
141
|
+
# unit (see {Indexer#reindex_file!}).
|
|
176
142
|
#
|
|
177
143
|
# @param source [String] the {Chunk#source} being replaced.
|
|
178
|
-
# @param chunks [Array<Chunk>] the new chunk set
|
|
179
|
-
#
|
|
144
|
+
# @param chunks [Array<Chunk>] the new chunk set (each +chunk.source+
|
|
145
|
+
# should equal +source+).
|
|
180
146
|
# @param vectors [Array<Array<Float>>] parallel to +chunks+.
|
|
181
147
|
# @return [void]
|
|
182
|
-
# @raise [ArgumentError] on empty input, length mismatch
|
|
183
|
-
# vector-dim mismatch (from the inner {#upsert}).
|
|
148
|
+
# @raise [ArgumentError] on empty input, length or vector-dim mismatch.
|
|
184
149
|
def replace_source(source:, chunks:, vectors:)
|
|
185
150
|
@lock.synchronize do
|
|
186
151
|
delete_by_source(source)
|
|
@@ -189,17 +154,13 @@ module Pikuri
|
|
|
189
154
|
nil
|
|
190
155
|
end
|
|
191
156
|
|
|
192
|
-
# The boot-sweep reference:
|
|
193
|
-
#
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
-
# Built from chunk metadata; a chunk indexed before the
|
|
197
|
-
# +hash+ metadata existed maps its source to +nil+, which the
|
|
198
|
-
# diff treats as "changed" and reindexes — self-healing.
|
|
157
|
+
# The boot-sweep reference: each indexed +source+ → the content hash on
|
|
158
|
+
# its chunks, which {Indexer#reconcile_plan} diffs against disk. A chunk
|
|
159
|
+
# with no +hash+ metadata maps to +nil+, which the diff treats as
|
|
160
|
+
# "changed" and reindexes — self-healing.
|
|
199
161
|
#
|
|
200
|
-
# @return [Hash{String => String, nil}] +source+ → content
|
|
201
|
-
#
|
|
202
|
-
# every boot, since RAM resets).
|
|
162
|
+
# @return [Hash{String => String, nil}] +source+ → content hash. Empty
|
|
163
|
+
# when nothing is indexed (the InMemory case at every boot).
|
|
203
164
|
def sources_with_hashes
|
|
204
165
|
@lock.synchronize do
|
|
205
166
|
result = {}
|
|
@@ -225,15 +186,12 @@ module Pikuri
|
|
|
225
186
|
|
|
226
187
|
private
|
|
227
188
|
|
|
228
|
-
# Cosine similarity
|
|
229
|
-
# micro-optimizing into a single loop saves an array
|
|
230
|
-
# traversal but obscures what's happening for the reader
|
|
231
|
-
# who's here to learn how a vector store works.
|
|
189
|
+
# Cosine similarity (readable two-pass — see the class header).
|
|
232
190
|
#
|
|
233
191
|
# @param a [Array<Float>]
|
|
234
192
|
# @param b [Array<Float>]
|
|
235
|
-
# @return [Float] cosine in +[-1.0, 1.0]+, or +0.0+ if
|
|
236
|
-
#
|
|
193
|
+
# @return [Float] cosine in +[-1.0, 1.0]+, or +0.0+ if either vector is
|
|
194
|
+
# zero (degenerate but valid input).
|
|
237
195
|
def cosine(a, b)
|
|
238
196
|
dot = a.zip(b).sum { |x, y| x * y }
|
|
239
197
|
mag_a = Math.sqrt(a.sum { |x| x * x })
|