Class: Pikuri::VectorDb::Backend::Chroma
- Inherits:
-
Object
- Object
- Pikuri::VectorDb::Backend::Chroma
- Defined in:
- lib/pikuri/vector_db/backend/chroma.rb
Overview
Thin Faraday HTTP client against a self-hosted Chroma server (v2 API),
targeting Chroma 0.5.x+. The persistent backend, behind the same
duck-typed Pikuri::VectorDb::Backend protocol as InMemory (same method names, return
shapes, and ArgumentError contract on empty input / non-positive
top_k; the vector-dim contract diverges — see below). Hand-rolled
rather than a chroma-db gem dep so the wire protocol stays auditable
in one readable file; the cost is tracking the v2 API by hand.
Two ways to get one: bring your own — +Chroma.new(host:, port:,
collection:)+ against an existing deployment (this class is purely the
HTTP client); or let pikuri manage it —
Server::Chroma.ensure_running's #client(collection:) returns a
Chroma pointed at a supervised container. Docker lifecycle and HTTP
wire protocol share nothing, so each is its own class.
v2 endpoints used: collections (get-or-create), .../upsert,
.../query, .../count, DELETE .../{id}, .../delete (where-filtered),
.../get (where-filtered projection).
BYO embeddings (Chroma's embedder is never invoked)
Chroma collections can carry a server-side embedding function; we always
send pre-computed embeddings and never use it. pikuri's Embedder is
the single source of truth — a parallel Chroma-side embedder would split
it invisibly (local embedder in pikuri + OpenAIEmbeddingFunction in
Chroma ⇒ every indexed document silently lands at OpenAI).
Contracts
- Vector-dim diverges from InMemory: enforced server-side (first
upsert sets the dim; a mismatch is HTTP 4xx →
RuntimeError, not theArgumentErrorInMemory raises). Same loud-failure shape; not worth parsing Chroma's error envelope to coerce the class. - Lazy collection resolution:
newdoesn't touch the server; the first +#upsert+/+#query+/+#count+ resolves (creating if missing) the collection by name and caches the id.#delete_allclears it; the next#upsertre-creates. - Cosine: collection created
hnsw.space: 'cosine'; Chroma returns distance[0,2],#queryconverts via1 - distanceso the Result score matches InMemory's similarity scale. - Metadata keys: JSON round-trips Symbol keys to Strings, so
#upsertstringifies and#queryre-symbolizes — a queried Chunk looks identical to one stored in InMemory.sourcerides as a reserved metadata key (Chroma has no nativesource).
Constant Summary collapse
- MANIFEST_PAGE_SIZE =
Rows per
/getpage in #sources_with_hashes — caps the parse working set of the boot manifest on a large corpus (one row per file, so a 50k-file corpus is ~50 localhost round trips, not one multi-MB response). Small corpora finish in one page. 1_000
Instance Method Summary collapse
-
#count ⇒ Integer
Current chunk count.
-
#delete_all ⇒ void
Drop the collection (the nuke-and-reload reindex path the Indexer drives); the next
#upsertre-creates. -
#delete_by_source(source) ⇒ void
Remove every chunk whose
sourcematches, via a metadata-filteredPOST .../delete(+source+ is the reserved metadata key #upsert writes). - #initialize(host:, port:, collection:, tenant: 'default_tenant', database: 'default_database', connection: nil) ⇒ Chroma constructor
-
#query(vector:, top_k:) ⇒ Array<Backend::Result>
k-NN query by cosine similarity.
-
#replace_source(source:, chunks:, vectors:) ⇒ void
Replace all chunks for one
source: delete the old set, then upsert the new (the incremental-reindex unit, Indexer#reindex_file!). -
#source_indexed?(source) ⇒ Boolean
Is
sourcein the corpus? Scoped existence check for Tools::Read's membership gate: a +where+-filtered/getcapped at one row,include: []so the response carries only ids — O(1) transport regardless of corpus size, never the full #sources_with_hashes manifest. -
#sources_with_hashes ⇒ Hash{String => String, nil}
The boot-sweep reference:
source→ stored content hash, one metadata row per file (not per chunk) viawhere: { offset: 0 }(every file has one chunk at offset 0),include: ['metadatas'](drops the heavy embeddings/documents), and MANIFEST_PAGE_SIZE +limit+/+offset+ paging so a large corpus never materializes one multi-MB response. -
#upsert(chunks:, vectors:) ⇒ void
Insert-or-replace by
chunk.id(parallel equal-length arrays).
Constructor Details
#initialize(host:, port:, collection:, tenant: 'default_tenant', database: 'default_database', connection: nil) ⇒ Chroma
70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
# File 'lib/pikuri/vector_db/backend/chroma.rb', line 70 def initialize(host:, port:, collection:, tenant: 'default_tenant', database: 'default_database', connection: nil) raise ArgumentError, 'host must be non-empty' if host.nil? || host.to_s.empty? raise ArgumentError, 'collection must be non-empty' if collection.nil? || collection.to_s.empty? @host = host @port = port @collection_name = collection @tenant = tenant @database = database @collection_id = nil @connection = connection || Faraday.new(url: "http://#{host}:#{port}") do |f| f.request :json f.response :json f.adapter Faraday.default_adapter end end |
Instance Method Details
#count ⇒ Integer
Returns current chunk count. Zero before the
first #upsert.
192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 |
# File 'lib/pikuri/vector_db/backend/chroma.rb', line 192 def count return 0 if @collection_id.nil? && !collection_exists? response = @connection.get("#{collection_path}/count") unless response.status == 200 raise "Backend::Chroma: GET #{collection_path}/count returned " \ "HTTP #{response.status}: #{response.body.inspect}" end body = response.body # Chroma v2 returns the count as a bare integer. return body if body.is_a?(Integer) return body['count'] if body.is_a?(Hash) && body['count'].is_a?(Integer) raise "Backend::Chroma: count response was not an Integer (got #{body.inspect})" end |
#delete_all ⇒ void
This method returns an undefined value.
Drop the collection (the nuke-and-reload reindex path the Indexer
drives); the next #upsert re-creates. No-op if none was created; a
404 on the DELETE is treated as "already gone" — idempotent.
178 179 180 181 182 183 184 185 186 187 188 |
# File 'lib/pikuri/vector_db/backend/chroma.rb', line 178 def delete_all return nil if @collection_id.nil? && !collection_exists? response = @connection.delete(collection_path) unless [200, 204, 404].include?(response.status) raise "Backend::Chroma: DELETE #{collection_path} returned " \ "HTTP #{response.status}: #{response.body.inspect}" end @collection_id = nil nil end |
#delete_by_source(source) ⇒ void
This method returns an undefined value.
Remove every chunk whose source matches, via a
metadata-filtered POST .../delete (+source+ is the
reserved metadata key #upsert writes). The scoped
counterpart to #delete_all. No-op when the collection
doesn't exist yet.
218 219 220 221 222 223 |
# File 'lib/pikuri/vector_db/backend/chroma.rb', line 218 def delete_by_source(source) return nil if @collection_id.nil? && !collection_exists? post_json("#{collection_path}/delete", { where: { 'source' => source } }) nil end |
#query(vector:, top_k:) ⇒ Array<Backend::Result>
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 |
# File 'lib/pikuri/vector_db/backend/chroma.rb', line 137 def query(vector:, top_k:) raise ArgumentError, "top_k must be positive (got #{top_k})" if top_k <= 0 # If we've never upserted, the collection doesn't # exist yet — semantic answer is "no hits." return [] if @collection_id.nil? && !collection_exists? response_body = post_json("#{collection_path}/query", { query_embeddings: [vector], n_results: top_k, include: %w[documents metadatas distances] }) ids = (response_body['ids'] || [[]]).first || [] docs = (response_body['documents'] || [[]]).first || [] = (response_body['metadatas'] || [[]]).first || [] dists = (response_body['distances'] || [[]]).first || [] ids.each_with_index.map do |id, i| = [i] || {} # Pull +source+ back out of the metadata blob; # symbolize the remaining keys for round-trip # consistency with InMemory. source = ['source'] || '' = {} .each do |k, v| next if k == 'source' [k.to_sym] = v end chunk = Chunk.new(id: id, source: source, text: docs[i] || '', metadata: ) Result.new(chunk: chunk, score: 1.0 - dists[i].to_f) end end |
#replace_source(source:, chunks:, vectors:) ⇒ void
This method returns an undefined value.
Replace all chunks for one source: delete the old set, then upsert
the new (the incremental-reindex unit, Indexer#reindex_file!).
Not transactional (the InMemory divergence): two HTTP calls, so a
#query between them can see the source with zero chunks — a window
InMemory#replace_source closes with its monitor but Chroma can't. The
Indexer mitigates the common failure by embedding before calling
here, so an embedder outage never reaches this and the old chunks stay.
Delete-then-upsert, not the reverse — the reverse would delete the
just-written chunks.
242 243 244 245 246 |
# File 'lib/pikuri/vector_db/backend/chroma.rb', line 242 def replace_source(source:, chunks:, vectors:) delete_by_source(source) upsert(chunks: chunks, vectors: vectors) nil end |
#source_indexed?(source) ⇒ Boolean
Is source in the corpus? Scoped existence check for
Tools::Read's membership gate: a +where+-filtered
/get capped at one row, include: [] so the response
carries only ids — O(1) transport regardless of corpus
size, never the full #sources_with_hashes manifest. See
the Backend protocol yardoc.
298 299 300 301 302 303 304 305 306 307 308 |
# File 'lib/pikuri/vector_db/backend/chroma.rb', line 298 def source_indexed?(source) return false if @collection_id.nil? && !collection_exists? body = post_json("#{collection_path}/get", { where: { 'source' => source }, include: [], limit: 1 }) ids = body.is_a?(Hash) ? (body['ids'] || []) : [] !ids.empty? end |
#sources_with_hashes ⇒ Hash{String => String, nil}
The boot-sweep reference: source → stored content hash, one metadata
row per file (not per chunk) via where: { offset: 0 } (every file
has one chunk at offset 0), include: ['metadatas'] (drops the heavy
embeddings/documents), and MANIFEST_PAGE_SIZE +limit+/+offset+
paging so a large corpus never materializes one multi-MB response.
(Two +offset+s collide in wording: the where one is a chunk
metadata field, the top-level one is the pagination cursor.)
Pagination assumes the manifest isn't mutating mid-read; the Watcher drives this from its single worker thread, so no reindex runs concurrently.
263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 |
# File 'lib/pikuri/vector_db/backend/chroma.rb', line 263 def sources_with_hashes return {} if @collection_id.nil? && !collection_exists? result = {} cursor = 0 loop do body = post_json("#{collection_path}/get", { where: { 'offset' => 0 }, include: ['metadatas'], limit: MANIFEST_PAGE_SIZE, offset: cursor }) = body.is_a?(Hash) ? (body['metadatas'] || []) : [] .each do || next unless .is_a?(Hash) && ['source'] result[['source']] = ['hash'] end break if .size < MANIFEST_PAGE_SIZE cursor += .size end result end |
#upsert(chunks:, vectors:) ⇒ void
This method returns an undefined value.
Insert-or-replace by chunk.id (parallel equal-length arrays).
Vector-dim mismatch surfaces as RuntimeError server-side (InMemory
raises ArgumentError — see the class header).
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 |
# File 'lib/pikuri/vector_db/backend/chroma.rb', line 99 def upsert(chunks:, vectors:) raise ArgumentError, 'upsert called with empty chunks/vectors' if chunks.empty? if chunks.size != vectors.size raise ArgumentError, "size mismatch: #{chunks.size} chunks vs #{vectors.size} vectors" end ensure_collection! = chunks.map do |c| # Serialize +source+ as a reserved key in Chroma's # +metadata+; merge in the user's metadata Hash with # keys stringified for JSON round-trip stability. base = { 'source' => c.source } c..each { |k, v| base[k.to_s] = v } base end body = { ids: chunks.map(&:id), embeddings: vectors, documents: chunks.map(&:text), metadatas: } post_json("#{collection_path}/upsert", body) nil end |