feat(v2): add validate_schema option and skip embedding column in similarity search - #330
Conversation
…ilarity search AsyncPGVectorStore.create() always queried information_schema.columns to validate the configured columns, with no way to skip it for a caller that already knows the schema is valid (e.g. a short-lived store constructed per request in a serverless/edge function). Add validate_schema: bool = True to opt out of that round trip; it can't be combined with ignore_metadata_columns, since resolving that option requires the full column list. __query_collection also always selected the embedding column, even though plain similarity search never reads it back (only MMR does, via a separate call). Add an internal include_embedding flag (default True) and have asimilarity_search_with_score_by_vector opt out of it by default, since the returned Document never carries the embedding either way. Both changes are additive/opt-in and preserve existing default behavior. Fixes langchain-ai#326 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTbEx2hGwCZyQQtXZ2pDck
There was a problem hiding this comment.
The new validate_schema=False option removes the only guard that prevented unsanitized column-name strings from being interpolated directly into SQL identifiers; applications that derive any of the store's column or table configuration from external input when using this flag are vulnerable to SQL injection under the configured Postgres role.
| metadata_json_column = ( | ||
| None if metadata_json_column not in columns else metadata_json_column | ||
| ) | ||
| if validate_schema: |
There was a problem hiding this comment.
The validate_schema=False branch skips the information_schema-backed check that was the only guard preventing unsanitized column names from reaching SQL interpolation. With this flag set, any value passed as id_column, content_column, embedding_column, metadata_columns, metadata_json_column, or hybrid_search_config.tsv_column is stored verbatim and later interpolated into f-string queries (INSERT at line 330, SELECT at line 701, DELETE at line 491, hybrid-search at line 742) with only double-quote wrapping — wrapping a " in the payload trivially breaks.
if not validate_schema and ignore_metadata_columns:
raise ValueError(
"Can not use both validate_schema=False and ignore_metadata_columns, "
"since resolving ignore_metadata_columns requires querying the full "
"column list. Pass metadata_columns explicitly instead."
)
if validate_schema:
# Get field type informationRemediation: Apply an unconditional identifier-format check (e.g. ^[A-Za-z_][A-Za-z0-9_]{0,62}$) to every column and table name before they are stored, independent of validate_schema. The flag should only skip the database round-trip existence/type probe, not local format validation.
Attack Path
- Application constructs the store per request with
validate_schema=False, reading column names from tenant config or a request parameter. - Attacker supplies a payload like
col", NULL); DROP TABLE docs; --as a metadata column name. create()stores it verbatim — noinformation_schemacheck runs.aadd_embeddings(line 330) interpolates it into the INSERT statement, breaking out of the quoted-identifier context.- The injected SQL executes under the application's Postgres role.
For more details, see the finding in Corridor.
Provide feedback: Reply with whether this is a valid vulnerability or false positive to help improve Corridor's accuracy.
Fixes #326.
What
Two small, additive/opt-in changes to
AsyncPGVectorStore(and thePGVectorStoresync wrapper, which just forwards to it):create()gets avalidate_schema: bool = Trueoption. WhenFalse, it skips theinformation_schema.columnsround trip used to validateid_column/content_column/embedding_column/metadata_columns, trusting the caller-provided configuration instead. This matters for callers that construct a short-lived store per request (e.g. a serverless/edge function that can't hold a long-lived instance across invocations) against a table whose schema is already known to be valid — today that pays for a full schema introspection query on every single request in addition to the actual similarity search query. Can't be combined withignore_metadata_columns, since resolving that option requires the full column list frominformation_schema.__query_collectiongets an internalinclude_embeddingflag (defaultTrue), andasimilarity_search_with_score_by_vectoropts out of it. The embedding column was always included in the SELECT list, even though plain similarity search never reads the embedding value back off the row (the returnedDocumentnever carries it) — onlyamax_marginal_relevance_search_with_score_by_vectoractually usesrow[self.embedding_column]. For a high-dimensional embedding (e.g. 1536-dim OpenAI) and a typicalk, that's several vectors of serialized floats transferred and immediately discarded per plain search. A caller can still opt back in withinclude_embedding=True.Neither change touches the SQL for the MMR path, and both preserve existing default behavior everywhere else.
Testing
uv sync --group test, then ran the fulltests/unit_tests/v2/suite against a localpgvector/pgvector-equivalent Postgres (POSTGRES_PORT=<local>): 585 passed (582 pre-existing + 3 new).test_create_with_validate_schema_false/test_create_with_validate_schema_false_and_ignore_metadata_columnstotest_async_pg_vectorstore.py.test_query_collection_include_embeddingtotest_async_pg_vectorstore_search.py.ruff check/ruff format --check/mypy langchain_postgresall clean on the changed files.Context
I filed #326 after noticing these while porting this package to TypeScript (
@yukiharada1228/langchain-postgres, a faithful hand-translation) for use in a Cloudflare Workers app, where a per-request store is unavoidable and the extra round trip is pure overhead on every request. Happy to adjust the API shape (naming, whetherinclude_embeddingshould be a public kwarg vs. stay internal-only) if you'd prefer something different.