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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/lib/pikuri/vector_db/backend/chroma.rb +76 -177
  4. data/lib/pikuri/vector_db/backend/in_memory.rb +40 -82
  5. data/lib/pikuri/vector_db/backend/qdrant.rb +50 -131
  6. data/lib/pikuri/vector_db/backend/result.rb +12 -35
  7. data/lib/pikuri/vector_db/backend.rb +33 -61
  8. data/lib/pikuri/vector_db/chunk.rb +24 -55
  9. data/lib/pikuri/vector_db/chunker/fixed_window.rb +16 -41
  10. data/lib/pikuri/vector_db/chunker.rb +12 -36
  11. data/lib/pikuri/vector_db/embedder.rb +10 -38
  12. data/lib/pikuri/vector_db/extension.rb +32 -75
  13. data/lib/pikuri/vector_db/indexer.rb +88 -175
  14. data/lib/pikuri/vector_db/librarian.rb +20 -53
  15. data/lib/pikuri/vector_db/reranker/hit.rb +8 -25
  16. data/lib/pikuri/vector_db/reranker/llama_server.rb +15 -59
  17. data/lib/pikuri/vector_db/reranker.rb +16 -41
  18. data/lib/pikuri/vector_db/server/chroma.rb +19 -47
  19. data/lib/pikuri/vector_db/server/docker_container.rb +35 -74
  20. data/lib/pikuri/vector_db/server/in_memory.rb +15 -33
  21. data/lib/pikuri/vector_db/server/qdrant.rb +24 -50
  22. data/lib/pikuri/vector_db/server.rb +15 -25
  23. data/lib/pikuri/vector_db/tokenizer/char_heuristic.rb +9 -28
  24. data/lib/pikuri/vector_db/tokenizer/llama_server.rb +13 -37
  25. data/lib/pikuri/vector_db/tokenizer.rb +9 -46
  26. data/lib/pikuri/vector_db/tools/read.rb +39 -61
  27. data/lib/pikuri/vector_db/tools/reindex.rb +24 -38
  28. data/lib/pikuri/vector_db/tools/search.rb +56 -88
  29. data/lib/pikuri/vector_db/watcher.rb +49 -83
  30. data/lib/pikuri-vectordb.rb +19 -75
  31. data/prompts/pikuri-corpus.txt +1 -17
  32. metadata +5 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec687bac82be3582807685133b88fa03a93099fbf082854ea8e99a5447ae3e3b
4
- data.tar.gz: 8128964835740ade33e032f60aa71514997cd32a4c974fb4371031a30ba40e16
3
+ metadata.gz: 041cfbc12058abab95bdf6a38340c69096b49fc14b7c779aa8f30c2cece76153
4
+ data.tar.gz: 23e98ff1fcbdfae0fab697c632ec71cbb6a51ea6b28e32fbb80999b277cbd68a
5
5
  SHA512:
6
- metadata.gz: 906cae6365352f1483ef3aa9f06399dba225d34c663f5c5a6e1e1d47880ef7f4927943cb639387bd901cf56e0f2667905e35ab811438850aedf68240907566c5
7
- data.tar.gz: 3de16d8630e589d30a67067bfd36b06475840633a1c99ec451eaafb2f2cf8c9a448d80865d1c3b01d8ad0b00083ddb26f3342a44e1766c6f9ff80ccd661455af
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 `docs/guide/` (the pikuri guide itself)
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 3](../docs/guide/03-vectordb.md) is the full walkthrough.
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 1](../docs/guide/01-chat.md) walks through the setup;
161
- [chapter 3](../docs/guide/03-vectordb.md) adds the embedder and
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](../docs/guide/03-vectordb.md) — concepts, model setup, the
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
- # server (v2 API). The persistent backend, behind the same
11
- # duck-typed {Backend} protocol as {InMemory}: same method
12
- # names, same return shapes, same +ArgumentError+ contract
13
- # on empty input + non-positive +top_k+. Where the two
14
- # diverge is the vector-dim contract — see below.
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
- # The client is hand-rolled rather than a dependency on a
17
- # +chroma-db+ gem: only a handful of v2 endpoints are needed
18
- # (listed below), Faraday is already in the dependency closure,
19
- # and a thin first-party client keeps the wire protocol
20
- # auditable in one readable file — consistent with the
21
- # read-it-in-an-evening ceiling. The cost is tracking Chroma's
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
- # == Two ways to get one
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
- # * **Bring your own.** +Backend::Chroma.new(host:, port:,
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
- # == Chroma v2 API
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
- # Endpoints used:
36
+ # == Contracts
44
37
  #
45
- # * +POST /api/v2/tenants/{tenant}/databases/{db}/collections+
46
- # with +get_or_create: true+ — idempotent collection
47
- # creation. Returns +{id, name, ...}+.
48
- # * +POST /api/v2/.../collections/{id}/upsert+ — insert or
49
- # replace by id. Body carries parallel arrays of +ids+,
50
- # +embeddings+, +documents+, +metadatas+.
51
- # * +POST /api/v2/.../collections/{id}/query+ — k-NN
52
- # search. Body: +{query_embeddings, n_results, include}+.
53
- # * +GET /api/v2/.../collections/{id}/count+ — integer
54
- # count.
55
- # * +DELETE /api/v2/.../collections/{id}+ — drop the
56
- # collection (used by +#delete_all+).
57
- # * +POST /api/v2/.../collections/{id}/delete+ — metadata-
58
- # filtered delete (+{where:}+); used by +#delete_by_source+.
59
- # * +POST /api/v2/.../collections/{id}/get+ — fetch rows by
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}. Caps the
127
- # JSON burst + parse working set of the boot manifest read on
128
- # a large corpus; small corpora finish in one page. Chunky but
129
- # not arbitrary — one round trip per this-many *files*, and the
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 in Chroma.
138
- # This is a Chroma-specific identifier, so it lives
139
- # here rather than on +VectorDb::Extension+ (where
140
- # it'd be a no-op for +Backend::InMemory+).
141
- # @param tenant [String] Chroma v2 tenant; defaults to
142
- # Chroma's own default.
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+. Parallel arrays of
171
- # equal length; raises on empty input or length mismatch
172
- # (same contract as {InMemory}). Chroma server enforces
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. Next +#upsert+ re-creates from
258
- # scratch — that's the v1 nuke-and-reload reindex path
259
- # the {Indexer} drives. No-op if no collection was ever
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
- # then upsert the new one. The incremental-reindex unit
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
- # These are two HTTP calls, so a +#query+ landing between
319
- # them can see the source with zero chunks — a window
320
- # {InMemory#replace_source} closes with its monitor but
321
- # Chroma cannot, short of server-side transactions it
322
- # doesn't expose. The window is small and the
323
- # {Indexer} mitigates the *common* failure: it embeds
324
- # before calling here, so an embedder outage never reaches
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
- # for every indexed document. Reads one metadata row per
343
- # *file*, not per chunk, via three Chroma +/get+ knobs:
344
- #
345
- # * +where: { offset: 0 }+ — every file has exactly one
346
- # chunk at offset 0, so this returns one row per source.
347
- # * +include: ['metadatas']+ — drops the heavy +embeddings+
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
- # {Watcher} drives this from its single worker thread, so no
359
- # reindex runs concurrently with the boot sweep that calls it.
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
- # hash. Empty when the collection doesn't exist yet.
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. The educational default backend —
9
- # the "small enough to audit" first stop the demo + guide walk
10
- # through before promoting users to +Chroma+ for persistence.
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
- # == What it does
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
- # Holds an in-memory Hash from chunk id to +[Chunk, vector]+;
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
- # == What it deliberately doesn't do
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
- # * **No persistence.** RAM-only, intentional — the user who
26
- # wants persistence picks +Chroma+. Reloads from sources on
27
- # every boot, which makes the in-memory backend the natural
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
- # Dimension of every stored vector. +nil+ before the first
67
- # +#upsert+; locked to the dim of the first vector seen and
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 so +#replace_source+ can call +#delete_by_source+
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+: delete the
171
- # old set, then upsert the new one, under a single hold of the
172
- # monitor. The incremental-reindex unit (see {Indexer#reindex_file!}).
173
- # Holding the lock across both halves is the point — a
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; every
179
- # +chunk.source+ should equal +source+.
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, or
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: a map from each indexed +source+
193
- # to the content hash stored on its chunks. {Watcher} (via
194
- # {Indexer#reconcile_plan}) diffs this against the hashes of
195
- # the files currently on disk to decide what to reindex.
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
- # hash. Empty when nothing is indexed (the InMemory case at
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. Two-pass form is the readable shape;
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
- # either vector is zero (degenerate but valid input).
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 })