<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:fh="http://purl.org/syndication/history/1.0"><channel><title>Keyvector | Dev notes</title><description>Semantic search for Amazon Keyspaces, without moving your data. Open source, runs in your own AWS account.</description><link>https://keyvector.dev/</link><language>en</language><fh:complete/><atom:link rel="self" href="https://keyvector.dev/notes/rss.xml"/><item><title>One key per row: turning a Cassandra primary key into a vector key</title><link>https://keyvector.dev/notes/one-key-per-row/</link><guid isPermaLink="true">https://keyvector.dev/notes/one-key-per-row/</guid><description>The same row arrives as strings from the stream and as typed values from CQL. If they don&apos;t produce the same key, the index quietly holds two vectors for one row.</description><pubDate>Mon, 02 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every vector in S3 Vectors has a key, a string of up to 1,024 characters. Keyvector has to
choose one for each row, and three different paths have to agree on it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;stream&lt;/strong&gt;, when a row changes;&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;backfill&lt;/strong&gt;, when it reads existing rows through CQL;&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;query&lt;/strong&gt;, which turns a hit back into a row to read.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;use-the-whole-primary-key-written-so-it-can-be-read-back&quot;&gt;Use the whole primary key, written so it can be read back&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;AWS’s sample used a single text column as the key. That breaks the moment a table has a
composite primary key, which in Cassandra is most tables. Keyvector uses all the primary key
columns, sorted by name, written as &lt;code dir=&quot;auto&quot;&gt;column=value&lt;/code&gt; pairs joined by &lt;code dir=&quot;auto&quot;&gt;&amp;#x26;&lt;/code&gt;, with each part
percent-encoded:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;movie_id=12&amp;#x26;studio=Redwood%3DRidge&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Sorting makes the key independent of column order. Percent-encoding means a value containing
&lt;code dir=&quot;auto&quot;&gt;&amp;#x26;&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;=&lt;/code&gt; can’t be mistaken for a separator. Our test table deliberately has studios called
“Northlight &amp;#x26; Co” and “Redwood=Ridge”. And because the key is reversible, a query hit can be
turned straight into &lt;code dir=&quot;auto&quot;&gt;WHERE movie_id = ? AND studio = ?&lt;/code&gt; without storing anything else.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-harder-part-the-same-value-has-to-look-the-same-twice&quot;&gt;The harder part: the same value has to look the same twice&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A row reaches Keyvector in two shapes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;from the change stream&lt;/strong&gt;, every scalar is a string: an &lt;code dir=&quot;auto&quot;&gt;int&lt;/code&gt; is &lt;code dir=&quot;auto&quot;&gt;&quot;42&quot;&lt;/code&gt;, a timestamp is
text in a format AWS’s docs don’t pin down;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;from CQL&lt;/strong&gt;, values are typed Java objects: &lt;code dir=&quot;auto&quot;&gt;Integer&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Instant&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;UUID&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;ByteBuffer&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If the two produce different text for the same value, the backfill and a later update write
the same row under two keys, and the index silently holds both.&lt;/p&gt;
&lt;p&gt;So both paths normalize to one form before a key is built:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;every integer type becomes a &lt;code dir=&quot;auto&quot;&gt;Long&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;float&lt;/code&gt; is read through its shortest decimal form, so &lt;code dir=&quot;auto&quot;&gt;0.1f&lt;/code&gt; is &lt;code dir=&quot;auto&quot;&gt;0.1&lt;/code&gt; and not
&lt;code dir=&quot;auto&quot;&gt;0.10000000149&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;a timestamp becomes epoch milliseconds, whether it arrived as digits, as ISO-8601 or as
CQL’s &lt;code dir=&quot;auto&quot;&gt;yyyy-MM-dd HH:mm:ss.SSSZ&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;a blob becomes hex.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A unit test feeds every key type through both paths and requires identical keys. The live test
then backfilled 302 rows and streamed 61 changes over them, ending with exactly one vector per
row.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;keys-that-are-too-long&quot;&gt;Keys that are too long&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;S3 Vectors keys stop at 1,024 characters. A row whose encoded primary key is longer can never
be stored, and retrying won’t change that, so Keyvector logs it and skips it rather than
stalling the shard behind it forever. In practice a primary key that long is rare.&lt;/p&gt;
</content:encoded><category>design</category></item><item><title>What broke when we ran it against real AWS</title><link>https://keyvector.dev/notes/what-broke-against-real-aws/</link><guid isPermaLink="true">https://keyvector.dev/notes/what-broke-against-real-aws/</guid><description>A 60-request-a-minute quota nobody can raise, a shutdown that never finished, and a 12-minute wait we hadn&apos;t planned for.</description><pubDate>Mon, 02 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Unit tests can tell you a planner makes the right decision. They can’t tell you what Bedrock
does to a new account, or what KCL does the first time it starts. So we built a test kit
(&lt;a href=&quot;https://github.com/keyvector/keyvector/tree/main/e2e&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;e2e/&lt;/code&gt;&lt;/a&gt;) and ran Keyvector against a
real Keyspaces table, a real change stream and real S3 Vectors indexes. We embedded with both
Amazon Bedrock Titan V2 and a local Hugging Face TEI running all-MiniLM-L6-v2. The whole thing
cost well under a dollar.&lt;/p&gt;
&lt;p&gt;The sync logic held up: every one of 61 live changes was accounted for, a restart resumed at
exactly the right place, and stopping the model server mid-run lost nothing. Here’s what
didn’t hold up.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;titan-v2-allows-60-requests-a-minute-and-you-cant-raise-it&quot;&gt;Titan V2 allows 60 requests a minute, and you can’t raise it&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;On a new account, on-demand &lt;strong&gt;Titan Text Embeddings V2 is limited to 60 requests a minute&lt;/strong&gt;,
and Service Quotas lists that quota as not adjustable. Titan embeds one text per request,
so that’s one row per second. A 1M-row backfill through Titan would take about 11.5 days.&lt;/p&gt;
&lt;p&gt;That turned up our first bug: &lt;strong&gt;the Bedrock embedder didn’t retry throttling.&lt;/strong&gt; It decided what
to retry by asking the SDK’s &lt;code dir=&quot;auto&quot;&gt;retryable()&lt;/code&gt; and searching the error message for
“ThrottlingException”. But the SDK reports a throttle it has already retried as &lt;em&gt;not&lt;/em&gt;
retryable, and the message says “Too many requests”. So the first throttle failed the whole
batch. In the stream, the processor’s own batch retry would eventually have saved it; in a
backfill, the run simply died, though it had written nothing.&lt;/p&gt;
&lt;p&gt;Two fixes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Retry on the actual signal:&lt;/strong&gt; a throttling or 429 status, a 5xx, or a network error.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pace requests to the quota in the first place.&lt;/strong&gt; Keyvector now spaces requests across all
its threads to a per-minute limit, which defaults to 60 for Titan. The rerun of the same
backfill took 5½ minutes for 320 rows, with zero throttles.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The bigger lesson is about the product, not the code. If a table changes faster than one row a
second, or needs a big backfill, use TEI, Cohere Embed (96 texts per request) or Bedrock batch
inference. “Bring your own model” turns out to be about throughput, not only privacy.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-first-start-waits-12-minutes&quot;&gt;The first start waits 12 minutes&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;On its first start, the stream worker did nothing for twelve minutes. KCL 3 adds a secondary
index to its DynamoDB lease table and assigns no shard until the index is active. That took
about 12 minutes per new application name, both times we measured. Later starts are immediate.
It’s documented now, because “nothing is happening” is the worst thing a first-time user can
see.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;shutdown-could-hang-forever&quot;&gt;Shutdown could hang forever&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;While KCL was waiting on that index, stopping the worker hung. Our shutdown hook waited for
KCL’s graceful shutdown without a timeout, and KCL can’t finish shutting down while it’s still
starting, so only &lt;code dir=&quot;auto&quot;&gt;kill -9&lt;/code&gt; worked. Now the hook gives up after 30 seconds. Nothing is lost by
giving up: an unacknowledged batch is simply read again by the next worker.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;a-busy-port-killed-the-startup-thread&quot;&gt;A busy port killed the startup thread&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Restarting quickly put a second worker on the same health-check port. Binding failed, the
exception escaped &lt;code dir=&quot;auto&quot;&gt;main&lt;/code&gt;, and the worker kept running with no health endpoint and a scary stack
trace. Now it logs the problem and carries on.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;aws-login-credentials-didnt-work&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;aws login&lt;/code&gt; credentials didn’t work&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The fork’s AWS SDK for Java (2.33) predated &lt;code dir=&quot;auto&quot;&gt;aws login&lt;/code&gt;, the CLI’s browser sign-in, so the
connector couldn’t use the credentials every new AWS user now has. Upgrading to 2.46 and adding
the &lt;code dir=&quot;auto&quot;&gt;signin&lt;/code&gt; module fixed it. The Python side needed &lt;code dir=&quot;auto&quot;&gt;boto3[crt]&lt;/code&gt; for the same reason.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-other-surprises&quot;&gt;The other surprises&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A new account’s Bedrock access is blocked while AWS verifies the account&lt;/strong&gt;, for up to two
hours. &lt;code dir=&quot;auto&quot;&gt;keyvector check&lt;/code&gt; caught it before anything ran, which is the reason it exists.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AWS’s “new AWS experience” accounts can’t use Amazon Keyspaces at all&lt;/strong&gt; unless you activate
advanced features. We tested in a classic account.&lt;/li&gt;
&lt;li&gt;KCL logs &lt;code dir=&quot;auto&quot;&gt;Incomplete hash range found&lt;/code&gt; every two minutes against Keyspaces streams. It
expects Kinesis-style hash ranges, which these streams don’t have. It’s noise; processing
is unaffected.&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>testing</category><category>bedrock</category><category>kcl</category></item><item><title>Why we forked the AWS sample, and what we changed</title><link>https://keyvector.dev/notes/why-we-forked-the-aws-sample/</link><guid isPermaLink="true">https://keyvector.dev/notes/why-we-forked-the-aws-sample/</guid><description>AWS&apos;s sample connector is a good start and a risky finish. Five problems, each invisible until data goes missing.</description><pubDate>Mon, 02 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In April 2026 AWS published a blog post and a
&lt;a href=&quot;https://github.com/aws-samples/sample-keyspaces-cdc-streams-connectors&quot;&gt;sample repository&lt;/a&gt;
showing how to stream Amazon Keyspaces changes into an S3 Vectors index. It’s MIT-0
licensed, it reads the change stream with the Kinesis Client Library (KCL), and it handles
the genuinely hard parts of stream processing: leases, checkpoints and shard splits. We kept
all of that.&lt;/p&gt;
&lt;p&gt;The vector connector itself is a demo, though, and reading it as if it would run in production
turned up five problems. None of them raises an error. Each one silently leaves the index wrong.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;1-a-failed-batch-was-skipped-silently&quot;&gt;1. A failed batch was skipped, silently&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;When applying a batch failed, the processor logged the error and returned. KCL then delivered
the next batch, and when that one succeeded, checkpointed past both. The rows in the failed
batch never got a vector, and nothing anywhere said so.&lt;/p&gt;
&lt;p&gt;Keyvector retries a failing batch with backoff and doesn’t save the worker’s position until
the batch is applied. After a configurable number of attempts it parks the rows’ keys in a
dead-letter queue and moves on. Without a queue it keeps retrying: a stalled shard is a
visible failure (the stream’s age climbs), a skipped batch isn’t.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;2-deletes-were-ignored&quot;&gt;2. Deletes were ignored&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The sample read only each change’s new image. A deleted or expired row has none, so its vector
stayed in the index forever, and searches kept finding rows that no longer existed.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;3-every-environment-variable-was-printed-at-startup&quot;&gt;3. Every environment variable was printed at startup&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The runner logged its whole environment, so an embedding API key would have ended up in
CloudWatch Logs. Keyvector logs no environment and reads keys from a named variable that ECS
fills from Secrets Manager.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;4-the-deployment-template-was-too-broad-and-wrong&quot;&gt;4. The deployment template was too broad, and wrong&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The CloudFormation template gave the task’s execution role full ECS access, and its task role
&lt;code dir=&quot;auto&quot;&gt;dynamodb:*&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;cassandra:*&lt;/code&gt; on every resource. It also set the table-name variable to the
&lt;em&gt;keyspace&lt;/em&gt; name.&lt;/p&gt;
&lt;p&gt;Keyvector’s template scopes every permission to one table, its stream, the connector’s own
lease tables, one vector index and one queue (see &lt;a href=&quot;https://keyvector.dev/reference/iam/&quot;&gt;IAM permissions&lt;/a&gt;).&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;5-two-settings-were-quietly-ignored&quot;&gt;5. Two settings were quietly ignored&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The stream:&lt;/strong&gt; with several streams on a table (CDC switched off and on again), the sample
took whichever was listed first, which could be a disabled one that never delivers anything.
Keyvector takes the newest enabled stream.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The region:&lt;/strong&gt; the sample configured the Cassandra driver’s SigV4 plugin with a key named
&lt;code dir=&quot;auto&quot;&gt;region&lt;/code&gt;. The plugin reads &lt;code dir=&quot;auto&quot;&gt;aws-region&lt;/code&gt;, so the setting did nothing and only worked because
&lt;code dir=&quot;auto&quot;&gt;AWS_REGION&lt;/code&gt; happened to be set.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;one-trap-that-isnt-in-the-sample&quot;&gt;One trap that isn’t in the sample&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A stream can record new images, old images, both, or keys only, and the choice can’t be changed
later. A connector that reads “no new image” as “row deleted”, which is the natural way to
detect deletes, would read every insert and update on a &lt;code dir=&quot;auto&quot;&gt;KEYS_ONLY&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;OLD_IMAGE&lt;/code&gt; stream as a
delete and empty the index. Keyvector checks the stream’s view type at startup and refuses to
run on one without new images.&lt;/p&gt;
&lt;p&gt;The sample did its job, which was to show the pattern. Keyvector’s job is to keep an index
right for months without anyone watching.&lt;/p&gt;
</content:encoded><category>design</category><category>reliability</category></item></channel></rss>