robot_lab-document_store 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fe6aeaf2a0fd6a0c1dd5b827a9382e3804cf9cf6e8573fdd9c75063fccd87a36
4
+ data.tar.gz: 350a44adfb40048c467166a1bfc37dd169941a35c4f5e3dd56ace185e39ba27b
5
+ SHA512:
6
+ metadata.gz: 05bc51a7b278d57d7bf8b1bb8535d5520ac41093112b0080c0f2f6e7eb554101d212b8c3065564f25a031ce3439f7b8ad9562fa93a9ef9a4091d78f3d20bdea2
7
+ data.tar.gz: 62694a390bbcd9a2492466eef6102fd821ae670ca30b694283d25f73f4fa7c504e9c62e2e32509ebc2f3f7700d63499e74153111bd781bf190ea39c76f714137
data/.envrc ADDED
@@ -0,0 +1 @@
1
+ export RR=`pwd`
@@ -0,0 +1,52 @@
1
+ name: Deploy Documentation to GitHub Pages
2
+ on:
3
+ push:
4
+ branches:
5
+ - main
6
+ - develop
7
+ paths:
8
+ - "docs/**"
9
+ - "mkdocs.yml"
10
+ - ".github/workflows/deploy-github-pages.yml"
11
+ workflow_dispatch:
12
+
13
+ permissions:
14
+ contents: write
15
+ pages: write
16
+ id-token: write
17
+
18
+ jobs:
19
+ deploy:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - name: Checkout code
23
+ uses: actions/checkout@v4
24
+ with:
25
+ fetch-depth: 0
26
+
27
+ - name: Setup Python
28
+ uses: actions/setup-python@v5
29
+ with:
30
+ python-version: 3.x
31
+
32
+ - name: Install dependencies
33
+ run: |
34
+ pip install mkdocs
35
+ pip install mkdocs-material
36
+ pip install mkdocs-macros-plugin
37
+ pip install mike
38
+
39
+ - name: Configure Git
40
+ run: |
41
+ git config --local user.email "action@github.com"
42
+ git config --local user.name "GitHub Action"
43
+
44
+ - name: Build MkDocs site
45
+ run: mkdocs build
46
+
47
+ - name: Deploy to GitHub Pages
48
+ uses: peaceiris/actions-gh-pages@v4
49
+ with:
50
+ github_token: ${{ secrets.GITHUB_TOKEN }}
51
+ publish_dir: ./site
52
+ keep_files: true
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-05-07
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Dewayne VanHoozer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # robot_lab-document_store
2
+
3
+ Embedding-based semantic document search for the [RobotLab](https://github.com/MadBomber/robot_lab) LLM agent framework.
4
+
5
+ > [!CAUTION]
6
+ > This gem is under active development. APIs may change without notice.
7
+
8
+ ## What it provides
9
+
10
+ `RobotLab::DocumentStore` is a thread-safe, in-memory vector store backed by [fastembed](https://github.com/Anush008/fastembed-ruby) embeddings and cosine similarity search. It supports:
11
+
12
+ - **`store(key, text)`** — embed and store a document under a symbol key
13
+ - **`search(query, limit:)`** — return the top-N most similar documents by cosine similarity
14
+ - **`delete(key)`** / **`clear`** — remove individual entries or wipe the store
15
+ - **Asymmetric embedding** — passage embeddings for storage, query embeddings for retrieval
16
+
17
+ ## Installation
18
+
19
+ Add to your Gemfile:
20
+
21
+ ```ruby
22
+ gem "robot_lab-document_store"
23
+ ```
24
+
25
+ ## Quick Example
26
+
27
+ ```ruby
28
+ require "robot_lab/document_store"
29
+
30
+ store = RobotLab::DocumentStore.new
31
+
32
+ store.store(:alpha, "Ruby is a dynamic, open source programming language.")
33
+ store.store(:beta, "Python is widely used in data science and machine learning.")
34
+ store.store(:gamma, "JavaScript runs in the browser and on Node.js servers.")
35
+
36
+ results = store.search("What language is popular for AI?", limit: 2)
37
+ results.each do |r|
38
+ puts "#{r[:key]} (score: #{"%.3f" % r[:score]})"
39
+ end
40
+ # => beta (score: 0.872)
41
+ # => alpha (score: 0.641)
42
+ ```
43
+
44
+ ## Custom Model
45
+
46
+ ```ruby
47
+ store = RobotLab::DocumentStore.new(
48
+ model_name: "BAAI/bge-small-en-v1.5"
49
+ )
50
+ ```
51
+
52
+ The default model is `"BAAI/bge-base-en-v1.5"`.
53
+
54
+ ## Using with RobotLab Robots
55
+
56
+ `DocumentStore` works well as in-memory retrieval for RAG (retrieval-augmented generation) workflows. Load documents at startup and pass relevant excerpts into robot context:
57
+
58
+ ```ruby
59
+ require "robot_lab"
60
+ require "robot_lab/document_store"
61
+
62
+ store = RobotLab::DocumentStore.new
63
+ store.store(:faq_1, "Our return policy allows returns within 30 days.")
64
+ store.store(:faq_2, "Shipping typically takes 3-5 business days.")
65
+
66
+ robot = RobotLab.build(
67
+ name: "support",
68
+ system_prompt: "You are a support agent. Use provided context to answer questions."
69
+ )
70
+
71
+ query = "How long do I have to return an item?"
72
+ chunks = store.search(query, limit: 2).map { |r| r[:text] }.join("\n")
73
+
74
+ result = robot.run("Context:\n#{chunks}\n\nQuestion: #{query}")
75
+ puts result.last_text_content
76
+ ```
77
+
78
+ ## Links
79
+
80
+ - [RobotLab Core](https://github.com/MadBomber/robot_lab)
81
+ - [fastembed-ruby](https://github.com/Anush008/fastembed-ruby)
82
+ - [RubyGems](https://rubygems.org/gems/robot_lab-document_store)
83
+
84
+ ## License
85
+
86
+ MIT License - Copyright (c) 2025 Dewayne VanHoozer
87
+
88
+ ## Contributing
89
+
90
+ Bug reports and pull requests are welcome on GitHub at https://github.com/MadBomber/robot_lab-document_store.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ task default: :test
data/docs/index.md ADDED
@@ -0,0 +1,58 @@
1
+ # robot_lab-document_store
2
+
3
+ Embedding-based semantic document search for the [RobotLab](https://github.com/MadBomber/robot_lab) LLM agent framework.
4
+
5
+ > [!CAUTION]
6
+ > This gem is under active development. APIs may change without notice.
7
+
8
+ ## What it provides
9
+
10
+ `RobotLab::DocumentStore` is a thread-safe, in-memory vector store backed by [fastembed](https://github.com/Anush008/fastembed-ruby) embeddings and cosine similarity search. It supports:
11
+
12
+ - **`store(key, text)`** — embed and store a document under a symbol key
13
+ - **`search(query, limit:)`** — return the top-N most similar documents by cosine similarity
14
+ - **`delete(key)`** / **`clear`** — remove individual entries or wipe the store
15
+ - **Asymmetric embedding** — passage embeddings for storage, query embeddings for retrieval
16
+
17
+ ## Installation
18
+
19
+ Add to your Gemfile:
20
+
21
+ ```ruby
22
+ gem "robot_lab-document_store"
23
+ ```
24
+
25
+ ## Quick Example
26
+
27
+ ```ruby
28
+ require "robot_lab/document_store"
29
+
30
+ store = RobotLab::DocumentStore.new
31
+
32
+ store.store(:alpha, "Ruby is a dynamic, open source programming language.")
33
+ store.store(:beta, "Python is widely used in data science and machine learning.")
34
+ store.store(:gamma, "JavaScript runs in the browser and on Node.js servers.")
35
+
36
+ results = store.search("What language is popular for AI?", limit: 2)
37
+ results.each do |r|
38
+ puts "#{r[:key]} (score: #{"%.3f" % r[:score]})"
39
+ end
40
+ # => beta (score: 0.872)
41
+ # => alpha (score: 0.641)
42
+ ```
43
+
44
+ ## Custom Model
45
+
46
+ ```ruby
47
+ store = RobotLab::DocumentStore.new(
48
+ model_name: "BAAI/bge-small-en-v1.5"
49
+ )
50
+ ```
51
+
52
+ The default model is `"BAAI/bge-base-en-v1.5"`.
53
+
54
+ ## Links
55
+
56
+ - [RobotLab Core](https://github.com/MadBomber/robot_lab)
57
+ - [fastembed-ruby](https://github.com/Anush008/fastembed-ruby)
58
+ - [RubyGems](https://rubygems.org/gems/robot_lab-document_store)
@@ -0,0 +1,52 @@
1
+ # Architecture Decision Record #047 — API Versioning Strategy
2
+
3
+ **Status:** Accepted (2024-11-12)
4
+ **Deciders:** Platform team, Mobile team, Partner integrations team
5
+
6
+ ## Context
7
+
8
+ The v1 API has accumulated 23 breaking changes held back by an informal freeze
9
+ while three external partners built integrations. The mobile apps ship on a
10
+ 4-week release cycle and cannot deploy hotfixes to force users to upgrade. We
11
+ need a versioning strategy that allows the backend to evolve without coordinated
12
+ lockstep releases across all consumers.
13
+
14
+ ## Decision
15
+
16
+ We adopt URI-based versioning (/api/v2/, /api/v3/) rather than header-based
17
+ (Accept: application/vnd.company.v2+json) for the following reasons:
18
+
19
+ - URI versioning is visible in logs, dashboards, and browser dev tools.
20
+ - Proxy and CDN rules can target specific version prefixes.
21
+ - Internal clients are all first-party and can be updated in lockstep.
22
+
23
+ Header-based versioning is reserved for minor non-breaking variants (e.g.,
24
+ adding optional fields) using the Prefer header.
25
+
26
+ ## Support Lifecycle
27
+
28
+ Each major version is supported for 18 months from GA. Deprecation notices are
29
+ added to response headers (Sunset: date) 6 months before EOL. The deprecation
30
+ dashboard tracks call volume per version per consumer; we do not retire a
31
+ version with > 100 calls/day without direct partner outreach.
32
+
33
+ ## Backwards Compatibility Rules
34
+
35
+ Within a version, we **may**:
36
+ - Add new fields to responses.
37
+ - Add new optional request parameters.
38
+ - Add new endpoints.
39
+ - Add new enum values (consumers must ignore unknown values).
40
+
41
+ We **must not**:
42
+ - Remove or rename fields.
43
+ - Change field types.
44
+ - Change HTTP status codes for existing success cases.
45
+ - Remove endpoints.
46
+
47
+ ## Migration Tooling
48
+
49
+ A version compatibility shim layer translates v1 requests to v2 internal
50
+ representations and back-translates responses. This allows v1 to remain
51
+ operational without duplicating business logic. The shim is tested with a
52
+ contract test suite against recorded v1 response fixtures.
@@ -0,0 +1,46 @@
1
+ # Incident Postmortem — INC-2024-089
2
+
3
+ **Date:** 2024-10-03
4
+ **Duration:** 47 minutes
5
+ **Severity:** P1
6
+ **Affected:** API gateway, order processing, checkout flows
7
+
8
+ ## Timeline
9
+
10
+ | Time | Event |
11
+ |-------|-------|
12
+ | 14:23 | Automated alert fires: p99 API latency exceeds 5 seconds |
13
+ | 14:25 | On-call engineer pages in; confirms checkout error rate at 34% |
14
+ | 14:31 | Identified spike in slow queries on orders table in Datadog APM |
15
+ | 14:38 | Root cause confirmed: migration added non-concurrent index at peak traffic |
16
+ | 14:44 | DBA kills the migration process; index creation aborted |
17
+ | 14:48 | Query latency returns to baseline; error rate drops to 0.2% |
18
+ | 15:10 | Full recovery confirmed; incident closed |
19
+
20
+ ## Root Cause
21
+
22
+ An engineer ran a schema migration that created an index on orders.status
23
+ without the CONCURRENTLY keyword. Postgres acquired an AccessExclusiveLock on
24
+ the orders table for the duration of the index build (11 minutes). All queries
25
+ touching the orders table queued behind the lock, exhausting the PgBouncer
26
+ connection pool within 3 minutes.
27
+
28
+ ## Contributing Factors
29
+
30
+ 1. Migration review checklist did not include "concurrent index" verification.
31
+ 2. The migration was run manually during business hours, not via the deploy pipeline.
32
+ 3. No automated linting (strong_migrations) was enforced in CI.
33
+
34
+ ## Remediation (Completed)
35
+
36
+ - `strong_migrations` gem added to Gemfile; CI fails on unsafe migration patterns.
37
+ - Runbook updated: all migrations that touch tables > 1M rows require DBA review.
38
+ - Index creation added to the concurrent-operations checklist.
39
+ - PgBouncer max_client_conn increased from 150 to 300 as a buffer.
40
+
41
+ ## Lessons Learned
42
+
43
+ Lock acquisition during index creation is silent in application logs — the first
44
+ visible symptom is connection pool exhaustion, not a database error.
45
+ Instrumenting pg_locks with an alert on long-held AccessExclusiveLocks would
46
+ have cut detection time from 8 minutes to under 1 minute.
@@ -0,0 +1,49 @@
1
+ # PostgreSQL Operations Runbook — v3.1
2
+
3
+ ## Slow Query Investigation
4
+
5
+ When a query exceeds 1 second, start with pg_stat_statements:
6
+
7
+ SELECT query, mean_exec_time, calls, total_exec_time
8
+ FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 20;
9
+
10
+ Use EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) on the top offenders.
11
+ Look for Sequential Scans on large tables (> 50k rows) and Hash Joins on
12
+ unindexed foreign keys. Missing index candidates appear as "rows removed by
13
+ filter" values that are an order of magnitude larger than the rows returned.
14
+
15
+ ## Connection Pool Exhaustion
16
+
17
+ PgBouncer pools connections at the transaction level. When all connections are
18
+ in use, new queries queue until pool_size is reached, at which point clients
19
+ receive "too many clients" errors. Mitigate by:
20
+ 1. Reducing max_connections per Rails process via database.yml pool setting.
21
+ 2. Increasing server_pool_size in pgbouncer.ini incrementally.
22
+ 3. Identifying and killing idle-in-transaction connections:
23
+
24
+ SELECT pid, state, query, now() - query_start AS duration
25
+ FROM pg_stat_activity WHERE state = 'idle in transaction'
26
+ AND query_start < now() - interval '30 seconds';
27
+
28
+ ## Table Bloat and Vacuum
29
+
30
+ High update/delete workloads generate table bloat. Check with:
31
+
32
+ SELECT relname, n_dead_tup, n_live_tup,
33
+ round(n_dead_tup::numeric / nullif(n_live_tup, 0) * 100, 1) AS dead_pct
34
+ FROM pg_stat_user_tables ORDER BY dead_pct DESC;
35
+
36
+ If dead_pct exceeds 20% on a hot table, trigger VACUUM ANALYZE manually. For
37
+ severe bloat, schedule an off-hours VACUUM FULL (acquires exclusive lock).
38
+ Autovacuum scale factor defaults to 0.2; reduce to 0.05 on high-churn tables.
39
+
40
+ ## Replication Lag
41
+
42
+ Monitor standby lag with:
43
+
44
+ SELECT client_addr, write_lag, flush_lag, replay_lag
45
+ FROM pg_stat_replication;
46
+
47
+ Lag above 30 seconds indicates the replica is falling behind writes. Common
48
+ causes: long-running VACUUM on primary holding WAL files, network saturation
49
+ between primary and replica, or index builds on the replica.
@@ -0,0 +1,48 @@
1
+ # Redis Caching Patterns — Implementation Guide
2
+
3
+ ## Cache Key Design
4
+
5
+ Keys must encode every dimension that affects the cached value. For a
6
+ user-scoped collection: `orders:user_USER_ID:page_PAGE:v2`. Always include a
7
+ version suffix (v2) so a code deploy can invalidate globally by bumping the
8
+ version, without a manual cache flush. Avoid encoding mutable data (e.g.,
9
+ user.plan) directly in the key; use separate keys and join at read time,
10
+ or accept stale reads.
11
+
12
+ ## TTL Strategy
13
+
14
+ Set TTLs based on acceptable staleness, not on intuition:
15
+
16
+ - User session data: 24h (refreshed on activity)
17
+ - API response cache (authenticated): 5 minutes
18
+ - API response cache (public, CDN-backed): 60 seconds
19
+ - Computed aggregates (dashboards): 15 minutes with background refresh
20
+ - Feature flags: 30 seconds (fast propagation of flag changes)
21
+
22
+ Always set a TTL. Unbounded keys are a production outage waiting to happen
23
+ when a runaway process fills the Redis instance.
24
+
25
+ ## Cache Invalidation
26
+
27
+ Explicit invalidation is more reliable than TTL-only for write-heavy data. Use
28
+ after_commit callbacks to delete or update cache entries when records change.
29
+ For collections, track the latest updated_at timestamp as the cache key
30
+ component (Russian doll caching). When multiple cache entries must be
31
+ invalidated atomically, use a Redis pipeline or Lua script.
32
+
33
+ ## Redis Memory Pressure
34
+
35
+ When Redis hits maxmemory, it evicts keys according to the eviction policy. Use
36
+ `allkeys-lru` for pure cache workloads. Monitor `evicted_keys` in Redis INFO; a
37
+ non-zero and growing value means your cache is too small for the working set.
38
+ Separate cache and session data into different Redis instances (or databases)
39
+ so session eviction cannot be triggered by cache pressure.
40
+
41
+ ## Stampede Protection
42
+
43
+ Under high read concurrency, a cache miss causes multiple processes to
44
+ simultaneously recompute the same expensive value — the cache stampede.
45
+ Mitigate with probabilistic early expiration: recompute when TTL drops below a
46
+ random fraction of the original TTL. Alternatively, use a distributed lock
47
+ (Redlock or a simple SET NX PX lock key) to allow only one process to recompute
48
+ while others wait briefly on the stale value.
@@ -0,0 +1,51 @@
1
+ # Background Job Processing with Sidekiq — Engineering Guide
2
+
3
+ ## Job Design Principles
4
+
5
+ Every Sidekiq job must be idempotent: running it twice with the same arguments
6
+ must produce the same outcome. This is non-negotiable because Sidekiq retries
7
+ failed jobs and at-least-once delivery is guaranteed, not exactly-once. Achieve
8
+ idempotency by checking preconditions (has this invoice already been generated?),
9
+ using database unique constraints on job output records, and passing Stripe
10
+ idempotency keys.
11
+
12
+ ## Retry Configuration
13
+
14
+ The default retry count is 25, which provides backoff up to ~21 days. For
15
+ time-sensitive jobs (send_welcome_email) reduce to 3. For financial jobs
16
+ (charge_subscription) raise to 15 to survive multi-hour outages.
17
+
18
+ Configure per-job: `sidekiq_options retry: 10`
19
+
20
+ Customize backoff with sidekiq_retry_in:
21
+
22
+ sidekiq_retry_in { |count| (count ** 4) + 15 + rand(30) * count }
23
+
24
+ This gives approximately: 15s, 1m, 5m, 17m, 34m for the first 5 retries.
25
+
26
+ ## Circuit Breaker Pattern
27
+
28
+ When a downstream service (Stripe, SendGrid) is degraded, jobs fail rapidly and
29
+ fill the retry queue, creating a thundering-herd effect when the service
30
+ recovers. Use a circuit breaker backed by Redis:
31
+
32
+ - Set `stripe:circuit_open` in Redis when 3 consecutive failures occur.
33
+ - In a job middleware, check the flag; if open, re-enqueue with 5-minute delay.
34
+ - Auto-clear the flag after 10 minutes using Redis TTL.
35
+
36
+ This converts retry churn into scheduled bursts.
37
+
38
+ ## Dead Queue Management
39
+
40
+ Jobs reach the dead queue after exhausting all retries. Never bulk-retry
41
+ blindly. Group dead jobs by error class, inspect a sample for root cause,
42
+ fix the underlying issue, then use a Rake task to re-enqueue in batches of 50
43
+ with a 1-second inter-batch sleep to avoid overwhelming the recovered service.
44
+ Log each re-enqueue with original args and failure reason.
45
+
46
+ ## Queue Priority and Latency Budgets
47
+
48
+ Define at least three queues: critical (< 1s SLA: auth, payments), default
49
+ (< 30s: email, webhooks), and bulk (< 1h: exports, reports). Run dedicated
50
+ Sidekiq processes per queue tier. Never mix critical and bulk work in the same
51
+ process — a spike of bulk jobs will starve critical work if they share a queue.
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Example 26: Embedding-Based Document Store
5
+ #
6
+ # Demonstrates Memory#store_document and Memory#search_documents — a
7
+ # lightweight RAG store backed by fastembed (BAAI/bge-small-en-v1.5).
8
+ #
9
+ # Documents are multi-paragraph engineering guides stored as Markdown files in:
10
+ # examples/26_document_store/
11
+ #
12
+ # Usage:
13
+ # ruby examples/26_document_store.rb
14
+ # (Downloads the ~23 MB ONNX model on first run; cached afterwards.)
15
+
16
+ require "robot_lab"
17
+ require "robot_lab/document_store"
18
+
19
+ puts "=" * 60
20
+ puts "Example 26: Embedding-Based Document Store"
21
+ puts "=" * 60
22
+ puts
23
+ puts "Note: First run downloads the fastembed model (~23 MB, cached)."
24
+ puts
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Load documents from the companion directory
28
+ # ---------------------------------------------------------------------------
29
+ DOC_DIR = File.join(__dir__, "26_document_store")
30
+
31
+ DOCUMENTS = Dir[File.join(DOC_DIR, "*.md")].sort.each_with_object({}) do |path, h|
32
+ key = File.basename(path, ".md").to_sym
33
+ h[key] = File.read(path)
34
+ end.freeze
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Store into a standalone DocumentStore
38
+ # ---------------------------------------------------------------------------
39
+ store = RobotLab::DocumentStore.new
40
+
41
+ print "Storing #{DOCUMENTS.size} documents... "
42
+ DOCUMENTS.each { |key, text| store.store(key, text) }
43
+ puts "done"
44
+ puts
45
+ DOCUMENTS.each { |key, text| puts " #{key.to_s.ljust(24)} #{text.split.size} words" }
46
+ puts
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Queries — each phrased differently from the document content
50
+ # ---------------------------------------------------------------------------
51
+ QUERIES = [
52
+ {
53
+ label: "Database query performance",
54
+ query: "Why is my Postgres query slow and how do I investigate it?",
55
+ want: :postgres_runbook
56
+ },
57
+ {
58
+ label: "Background job failures during outage",
59
+ query: "Jobs keep failing when Stripe is down. How do I stop them piling up?",
60
+ want: :sidekiq_guide
61
+ },
62
+ {
63
+ label: "API breaking changes policy",
64
+ query: "Can I rename a response field in the API without breaking clients?",
65
+ want: :api_versioning_adr
66
+ },
67
+ {
68
+ label: "Cache expiry and memory pressure",
69
+ query: "Redis is evicting keys unexpectedly and the cache hit rate has dropped.",
70
+ want: :redis_caching_guide
71
+ },
72
+ {
73
+ label: "Production outage from table lock",
74
+ query: "We had an outage caused by a database lock during a migration. What happened?",
75
+ want: :incident_postmortem
76
+ },
77
+ {
78
+ label: "Semantic gap — no shared keywords",
79
+ query: "Connection pool is full and new requests are being rejected.",
80
+ want: :postgres_runbook
81
+ },
82
+ ].freeze
83
+
84
+ QUERIES.each do |q|
85
+ results = store.search(q[:query], limit: 3)
86
+ top = results.first
87
+ verdict = top[:key] == q[:want] ? "✓ correct" : "✗ expected #{q[:want]}"
88
+
89
+ puts "── #{q[:label]}"
90
+ puts " Query: \"#{q[:query]}\""
91
+ puts " Top result: #{top[:key]} (#{format("%.3f", top[:score])}) — #{verdict}"
92
+ puts " Ranking: " + results.map { |r| "#{r[:key]} #{format("%.3f", r[:score])}" }.join(" | ")
93
+ puts
94
+ end
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Delete and verify
98
+ # ---------------------------------------------------------------------------
99
+ puts "── Delete :redis_caching_guide, re-run cache query"
100
+ store.delete(:redis_caching_guide)
101
+ results = store.search("Redis evicting keys unexpectedly", limit: 2)
102
+ puts " Remaining keys: #{store.keys.inspect}"
103
+ puts " Top result after deletion: #{results.first[:key]}"
104
+ puts
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Memory integration
108
+ # ---------------------------------------------------------------------------
109
+ puts "── Memory integration"
110
+ memory = RobotLab::Memory.new(enable_cache: false)
111
+
112
+ DOCUMENTS.each { |key, text| memory.store_document(key, text) }
113
+ puts " Stored #{memory.document_keys.size} documents via memory.store_document"
114
+
115
+ hits = memory.search_documents("slow query bloat vacuum autovacuum", limit: 2)
116
+ puts " Search 'slow query bloat vacuum autovacuum':"
117
+ hits.each { |h| puts " #{h[:key]} (#{format("%.3f", h[:score])})" }
118
+
119
+ memory.delete_document(:postgres_runbook)
120
+ puts " After delete, keys: #{memory.document_keys.inspect}"
121
+ puts
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # RAG pattern
125
+ # ---------------------------------------------------------------------------
126
+ puts "=" * 60
127
+ puts "RAG Pattern: retrieve relevant docs, then generate with LLM"
128
+ puts "=" * 60
129
+ puts
130
+
131
+ rag_query = "Our Sidekiq jobs exhaust retries and land in the dead queue after a Stripe outage."
132
+
133
+ hits = store.search(rag_query, limit: 2)
134
+ context = hits.map { |h| h[:text] }.join("\n\n---\n\n")
135
+
136
+ puts "User question:"
137
+ puts " \"#{rag_query}\""
138
+ puts
139
+ puts "Retrieved #{hits.size} document(s) — #{context.split.size} words of context:"
140
+ hits.each { |h| puts " #{h[:key]} (score #{format("%.3f", h[:score])})" }
141
+ puts
142
+ puts "LLM call would be:"
143
+ puts ' robot.run("Use the following docs:\n#{context}\n\nQuestion: #{rag_query}")'
144
+ puts
145
+
146
+ puts "Done."
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ class DocumentStore
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fastembed"
4
+ require_relative "document_store/version"
5
+
6
+ module RobotLab
7
+ # Embedding-based document store for semantic search over arbitrary text.
8
+ #
9
+ # Documents are embedded using fastembed (BAAI/bge-small-en-v1.5 by default)
10
+ # and stored in memory. Queries are embedded the same way, then compared by
11
+ # cosine similarity to find the closest documents.
12
+ #
13
+ # The embedding model is initialised lazily on first use — the ONNX model
14
+ # file is downloaded on that first call (cached locally afterwards).
15
+ #
16
+ # @example Standalone
17
+ # store = RobotLab::DocumentStore.new
18
+ # store.store(:q4_report, "Q4 revenue came in at $4.2M, up 18% YoY…")
19
+ # store.store(:q3_report, "Q3 showed 15% growth, driven by APAC…")
20
+ #
21
+ # results = store.search("revenue growth", limit: 2)
22
+ # results.each { |r| puts "#{r[:key]} (#{r[:score].round(3)}): #{r[:text][0..60]}" }
23
+ #
24
+ # @example With robot_lab Memory
25
+ # memory.store_document(:readme, File.read("README.md"))
26
+ # memory.search_documents("how to configure redis", limit: 3)
27
+ #
28
+ class DocumentStore
29
+ # Default embedding model used when none is specified.
30
+ DEFAULT_MODEL = "BAAI/bge-small-en-v1.5"
31
+
32
+ # @param model_name [String] fastembed model name
33
+ def initialize(model_name: DEFAULT_MODEL)
34
+ @model_name = model_name
35
+ @documents = {} # key (Symbol) => { text: String, vector: Array<Float> }
36
+ @mutex = Mutex.new
37
+ @model = nil # lazy: initialised on first embed call
38
+ end
39
+
40
+ # Embed +text+ and store it under +key+.
41
+ #
42
+ # If a document already exists under +key+ it is replaced.
43
+ #
44
+ # @param key [Symbol, String] identifier for this document
45
+ # @param text [String] the document text to embed and store
46
+ # @return [self]
47
+ def store(key, text)
48
+ key = key.to_sym
49
+ vector = passage_vector(text)
50
+ @mutex.synchronize { @documents[key] = { text: text, vector: vector } }
51
+ self
52
+ end
53
+
54
+ # Search for documents semantically similar to +query+.
55
+ #
56
+ # @param query [String] natural-language search query
57
+ # @param limit [Integer] maximum number of results (default 5)
58
+ # @return [Array<Hash>] results sorted by score descending.
59
+ # Each hash contains +:key+, +:text+, and +:score+ (Float 0.0..1.0).
60
+ def search(query, limit: 5)
61
+ return [] if empty?
62
+
63
+ query_vec = query_vector(query)
64
+ results = []
65
+
66
+ @mutex.synchronize do
67
+ @documents.each do |key, doc|
68
+ score = cosine_similarity(query_vec, doc[:vector])
69
+ results << { key: key, text: doc[:text], score: score }
70
+ end
71
+ end
72
+
73
+ results.sort_by { |r| -r[:score] }.first(limit)
74
+ end
75
+
76
+ # Number of stored documents.
77
+ # @return [Integer]
78
+ def size
79
+ @mutex.synchronize { @documents.size }
80
+ end
81
+
82
+ # Keys of all stored documents.
83
+ # @return [Array<Symbol>]
84
+ def keys
85
+ @mutex.synchronize { @documents.keys }
86
+ end
87
+
88
+ # Whether the store contains no documents.
89
+ # @return [Boolean]
90
+ def empty?
91
+ @mutex.synchronize { @documents.empty? }
92
+ end
93
+
94
+ # Remove the document stored under +key+.
95
+ # @param key [Symbol, String]
96
+ # @return [self]
97
+ def delete(key)
98
+ @mutex.synchronize { @documents.delete(key.to_sym) }
99
+ self
100
+ end
101
+
102
+ # Remove all stored documents.
103
+ # @return [self]
104
+ def clear
105
+ @mutex.synchronize { @documents.clear }
106
+ self
107
+ end
108
+
109
+ private
110
+
111
+ def model
112
+ @model ||= Fastembed::TextEmbedding.new(model_name: @model_name, show_progress: false)
113
+ end
114
+
115
+ def passage_vector(text)
116
+ model.passage_embed([text]).to_a.first
117
+ end
118
+
119
+ def query_vector(text)
120
+ model.query_embed([text]).to_a.first
121
+ end
122
+
123
+ def cosine_similarity(vec_a, vec_b)
124
+ return 0.0 unless vec_a && vec_b
125
+ return 0.0 if vec_a.empty? || vec_b.empty?
126
+ return 0.0 if vec_a.length != vec_b.length
127
+
128
+ dot = 0.0
129
+ norm_a = 0.0
130
+ norm_b = 0.0
131
+
132
+ vec_a.each_with_index do |a, i|
133
+ b = vec_b[i]
134
+ dot += a * b
135
+ norm_a += a * a
136
+ norm_b += b * b
137
+ end
138
+
139
+ return 0.0 if norm_a.zero? || norm_b.zero?
140
+
141
+ dot / (Math.sqrt(norm_a) * Math.sqrt(norm_b))
142
+ end
143
+ end
144
+ end
data/mkdocs.yml ADDED
@@ -0,0 +1,113 @@
1
+ site_name: robot_lab-document_store
2
+ site_description: Embedding-based semantic document search for the RobotLab LLM agent framework
3
+ site_author: Dewayne VanHoozer
4
+ site_url: https://madbomber.github.io/robot_lab-document_store
5
+ copyright: Copyright &copy; 2025 Dewayne VanHoozer
6
+
7
+ repo_name: MadBomber/robot_lab-document_store
8
+ repo_url: https://github.com/MadBomber/robot_lab-document_store
9
+ edit_uri: edit/main/docs/
10
+
11
+ theme:
12
+ name: material
13
+
14
+ palette:
15
+ - scheme: default
16
+ primary: blue
17
+ accent: amber
18
+ toggle:
19
+ icon: material/brightness-7
20
+ name: Switch to dark mode
21
+
22
+ - scheme: slate
23
+ primary: blue
24
+ accent: amber
25
+ toggle:
26
+ icon: material/brightness-4
27
+ name: Switch to light mode
28
+
29
+ font:
30
+ text: Roboto
31
+ code: Roboto Mono
32
+
33
+ icon:
34
+ repo: fontawesome/brands/github
35
+ logo: material/database-search
36
+
37
+ features:
38
+ - navigation.instant
39
+ - navigation.tracking
40
+ - navigation.tabs
41
+ - navigation.tabs.sticky
42
+ - navigation.path
43
+ - navigation.indexes
44
+ - navigation.top
45
+ - navigation.footer
46
+ - toc.follow
47
+ - search.suggest
48
+ - search.highlight
49
+ - search.share
50
+ - header.autohide
51
+ - content.code.copy
52
+ - content.code.annotate
53
+ - content.tabs.link
54
+ - content.tooltips
55
+ - content.action.edit
56
+ - content.action.view
57
+
58
+ plugins:
59
+ - search:
60
+ separator: '[\s\-,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])'
61
+
62
+ markdown_extensions:
63
+ - abbr
64
+ - admonition
65
+ - attr_list
66
+ - def_list
67
+ - footnotes
68
+ - md_in_html
69
+ - tables
70
+ - toc:
71
+ permalink: true
72
+ title: On this page
73
+ - pymdownx.betterem:
74
+ smart_enable: all
75
+ - pymdownx.caret
76
+ - pymdownx.details
77
+ - pymdownx.emoji:
78
+ emoji_generator: !!python/name:material.extensions.emoji.to_svg
79
+ emoji_index: !!python/name:material.extensions.emoji.twemoji
80
+ - pymdownx.highlight:
81
+ anchor_linenums: true
82
+ line_spans: __span
83
+ pygments_lang_class: true
84
+ - pymdownx.inlinehilite
85
+ - pymdownx.magiclink:
86
+ repo_url_shorthand: true
87
+ user: MadBomber
88
+ repo: robot_lab-document_store
89
+ normalize_issue_symbols: true
90
+ - pymdownx.mark
91
+ - pymdownx.smartsymbols
92
+ - pymdownx.superfences:
93
+ custom_fences:
94
+ - name: mermaid
95
+ class: mermaid
96
+ format: !!python/name:pymdownx.superfences.fence_code_format
97
+ - pymdownx.tabbed:
98
+ alternate_style: true
99
+ - pymdownx.tasklist:
100
+ custom_checkbox: true
101
+ - pymdownx.tilde
102
+
103
+ extra:
104
+ social:
105
+ - icon: fontawesome/brands/github
106
+ link: https://github.com/MadBomber/robot_lab-document_store
107
+ name: robot_lab-document_store on GitHub
108
+ - icon: fontawesome/solid/gem
109
+ link: https://rubygems.org/gems/robot_lab-document_store
110
+ name: robot_lab-document_store on RubyGems
111
+
112
+ nav:
113
+ - Home: index.md
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: robot_lab-document_store
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Dewayne VanHoozer
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: fastembed
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ description: Provides RobotLab::DocumentStore — a thread-safe, in-memory semantic
27
+ search store backed by fastembed (BAAI/bge-small-en-v1.5). Store text documents
28
+ by key and retrieve the closest matches to a natural-language query using cosine
29
+ similarity. Works standalone or as a drop-in extension for robot_lab agents and
30
+ networks.
31
+ email:
32
+ - dvanhoozer@gmail.com
33
+ executables: []
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - ".envrc"
38
+ - ".github/workflows/deploy-github-pages.yml"
39
+ - CHANGELOG.md
40
+ - LICENSE.txt
41
+ - README.md
42
+ - Rakefile
43
+ - docs/index.md
44
+ - examples/26_document_store.rb
45
+ - examples/26_document_store/api_versioning_adr.md
46
+ - examples/26_document_store/incident_postmortem.md
47
+ - examples/26_document_store/postgres_runbook.md
48
+ - examples/26_document_store/redis_caching_guide.md
49
+ - examples/26_document_store/sidekiq_guide.md
50
+ - lib/robot_lab/document_store.rb
51
+ - lib/robot_lab/document_store/version.rb
52
+ - mkdocs.yml
53
+ homepage: https://github.com/madbomber/robot_lab-document_store
54
+ licenses:
55
+ - MIT
56
+ metadata:
57
+ homepage_uri: https://github.com/madbomber/robot_lab-document_store
58
+ source_code_uri: https://github.com/madbomber/robot_lab-document_store
59
+ changelog_uri: https://github.com/madbomber/robot_lab-document_store/blob/main/CHANGELOG.md
60
+ rubygems_mfa_required: 'true'
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: 3.2.0
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubygems_version: 4.0.11
76
+ specification_version: 4
77
+ summary: Embedding-based semantic document store for RobotLab agents
78
+ test_files: []