Repository navigation
fix(graphile-llm): embed text inputs at plan time so Grafast sends vectors to SQL - #1953
theothersideofgod wants to merge 6 commits into
Conversation
…fast
Text sent as VectorNearbyInput.text, unifiedSearch text and {col}Text
mutation companion fields is never embedded: the plugins wrap resolve(),
which Grafast calls after the plan's SQL has run. VectorNearbyInput.vector
is also non-null, so a text-only search fails validation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ctors to SQL
Root cause: LlmTextSearchPlugin and LlmTextMutationPlugin embedded text by
wrapping the root field's resolve(). Under Grafast, resolve() runs after
the operation plan has executed, so the SQL was already built and sent
without a vector: text search returned unranked rows, unifiedSearch ran
without pgvector, and {col}Text on create/update stored NULL. The embedder
was never called.
Mechanism: input fields that carry text to embed are tagged with an
EmbedRule in their field extensions where they are declared
(VectorNearbyInput.text, unifiedSearch, {col}Text). In finalize,
planTimeEmbedding wraps the plan of every object field whose arguments can
reach a tagged field and shadows fieldArgs.getRaw for those arguments, so
connection-filter's where applyPlan and the create/update args.apply read
a lambda step that embeds the text before SQL is built. One mechanism
covers search and mutations, and both now apply the per-database
llm_module model overrides.
Behavior:
- VectorNearbyInput.vector is optional; a non-empty vector wins over
text; neither is an error. onQuotaExceeded 'degrade' drops the text,
'throw' errors. With no embedder, search text is ignored as before.
- On a create input for a NOT NULL vector column the vector field is
optional; one of {col} or {col}Text is required. A null embed or a
missing embedder fails the mutation.
- The embedder's {embedding} and number[] | null shapes are normalised
in one place (toVectorEmbedder).
embedTextInWhere is removed; its only callers were this package's plugin
and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…yclic filter types A depth-first walk memoized the provisional false it set to break a cycle, so a filter type first reached through a relation cycle was recorded as unable to reach a tagged field and its text was never embedded. Also build the getRaw shadow's access/object steps in the root layer plan like grafast's own getRaw, and skip loosening VectorNearbyInput.vector when another plugin registered the type without it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review complete. 🟠 1 high 📍 Findings outside the diff (1) — 🟠 1 high — defects on lines GitHub can't attach comments to🟠 High — Plan-time embedding bypasses metering quota checks · graphile/graphile-llm/src/plugins/metering-plugin.ts Embedding now runs inside grafast 🧹 Nitpicks (3) — 🟢 3 low
This PR reworks embedding in
Reviewed commit: c1402b6 |
This comment has been minimized.
This comment has been minimized.
…-sink query
The mega query passed vectorEmbedding: { nearby: { embedding, distance } }.
VectorNearbyInput has no nearby field, so a validating server rejects the
query; graphile-test skips validation, the adapter saw no vector and the
pgvector filter was silently dropped. Send { vector, metric, distance },
the filter the test's comment describes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The metering wrapper's number[] shape passed an empty array through as a vector, so a search silently applied no vector filter instead of taking the degrade or throw path, and a mutation wrote an empty vector. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plugins no longer call it, but it is public API of the published package, so removing it would be a breaking change. Restore it unchanged, mark it deprecated in favour of plan-time embedding, and restore its tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review notesThis comment is the full record behind the PR description, for human and agent reviewers. Every claim below cites a file and line, a command and its output, or a PR. Anything not verified is marked not verified. 1. What users get
2. Root causes
3. How the fix works
4. Behavior decisions
5. Public API and schema
6. Verification
7. Review history
8. Known gaps, not fixed hereTracked in constructive-io/constructive-planning#2437.
9. Not verified
10. Related
|
|
Thanks. Triage of each finding: High: plan-time embedding bypasses metering. The reasoning is right. The embeds now run before the metering context exists. No environment can reach it today, though, because the metering plugin cannot be turned on. On
Low: per-embed Low: Low: empty-array embedding bypasses null handling. Fixed in 54f3648. An empty vector from either embedder shape is now a failed embed, so search degrades or throws and a mutation throws. A test fails before the fix and passes after it. |
Why
A tenant who uploads a text file and then searches with plain text gets nothing useful.
vectorEmbedding: { text }fails validation, becausevectoris required.unifiedSearchtext and{column}Textmutations do not embed. graphile-llm embedded the text inside a wrappedresolve(). Grafast callsresolve()after the plan's SQL has already run. So the search gets no vector, and the mutation reports success but writes a NULL vector. This change embeds the text while the operation is planned, before Grafast builds the SQL.What changed
embed-inputs.ts. Each input field that carries text is tagged with an embed rule where it is declared. Afinalizepass wraps the plan of each field whose arguments can reach a tagged field. The wrapper replacesfieldArgs.getRaw, so connection-filter'swhereand the create and updateargs.applyread a step that embeds the text first.text-search-plugin.tsandtext-mutation-plugin.tslose theirresolve()wrappers and only tag fields.VectorNearbyInput.vectorbecomes optional when graphile-llm is loaded. On a create input, the vector field becomes optional when its{column}Textcompanion exists.build.llmEmbedderreturns{ embedding }, ornumber[] | nullwhen metering wraps it. Per-database model overrides now apply to mutations too, not only to search.Scope
vectorEmbedding.text,unifiedSearch, and{column}Texton create and update. Keeps the current rules: a non-emptyvectorwins overtext, search degrades on a failed embed by default, and a mutation throws on a failed embed.resolve(), so the embeds this PR moves to plan time run outside it. Today no environment can reach this. Onmain,GraphileLlmPreset({ metering: true })fails at schema build with allmEmbeddernaming conflict. With the conflict bypassed, its root-fieldresolve()wrapper fails planning for connection and mutation fields. The metering redesign is tracked in constructive-io/constructive-planning#2437.graphile-upload-plugin(multipart*Upload, sameresolve()problem, owner decision pending) andrag-plugin.ts, which reads{ embedding }and breaks when metering returns an array.Tradeoffs
fieldArgs.getRawdepends on grafast 1.1.2 readinggetRawthrough the FieldArgs object (operationPlan-input.js). The tests fail loudly if an upgrade changes that.whereargument'sapplyPlan. It copies connection-filter's body and does not reach mutations.executemiddleware that embeds before planning. It must rewrite the document or variables, and it changes plan-cache behavior.Blast Radius
Only fields whose arguments can reach a tagged field are wrapped. Fields with no vector or
unifiedSearchinput are untouched. Generated SDKs will seevector, and the create-input vector field, as optional after the next codegen. Clients that send vectors keep working. The public API only grows.embedTextInWherestays exported unchanged and is marked deprecated, because the plugins no longer call it. Draft #1744 also editsgraphile-llm.test.tsand may conflict. AVectorNearbyInputwith neithervectornortextis now an error, not a silently dropped filter. That found thegraphile-settingskitchen-sink query sending{ nearby: { embedding } }. That field does not exist, and real validation rejects it. The test now sends{ vector, metric, distance }.Verification
graphile-llm: the newplan-time-embedding.test.tsfails 5 of 5 on the test-only commit, then passes 5 of 5 with the fix. Real Postgres and Ollama.graphile-llmsuite: 76 passed.embed-inputs.test.tsruns realgraphql.validateand covers both embedder shapes, degrade and throw, and cyclic filter types.Field "VectorNearbyInput.vector" of required type "[Float!]!" was not provided.After, it ranked the matching file first at distance 0.286627. That matches a client-sent vector from the same Ollama exactly. An update with a{column}Textvalue stored a 768-dimension vector at cosine distance 0.0 from that Ollama's embedding.Full record for reviewers (root causes with file:line, mechanism, decisions, test matrix, known gaps, what is not verified): #1953 (comment)
🤖 Generated with Claude Code