Cache the "Title" placeholder embedding used for Pinecone vector lookups
## Background
`AIRelatedContentNodeFilter::getVectorFromNode()` fetches the *already-indexed*
vector for the current node so that finding related content never needs to
re-embed the node's own text. That part works as intended.
However, when the configured VDB backend is Pinecone
(`src/Plugin/views/filter/AIRelatedContentNodeFilter.php`, ~line 298-310),
Pinecone has no way to do a pure ID/metadata-only lookup — every query to it
must include a vector. The code works around this with:
```php
if ($vdb_plugin_id === 'pinecone') {
$query->keys('Title');
$query->addCondition('nid', (int) $node->id());
}
```
Setting `->keys('Title')` means Search API has no `vector_input` option set on
*this inner query*, so `SearchApiAiSearchBackend::getSearchVectorInput()`
(in the `ai_search` module) falls through to its "generate a new embedding
from search keys" branch and calls OpenAI's embeddings endpoint (plus a
moderation call — see the companion task in `ai_search`) for the literal
string `"Title"`, on every single page view of a Related Content block on a
Pinecone-backed site. The input is a constant, so the resulting vector never
changes for a given provider+model — this is a pure waste of an API call that
also adds latency and rate-limit pressure.
## Goal
Compute the "Title" embedding once, cache it, and supply it directly via the
`vector_input` query option instead of `->keys('Title')` — this makes
`getSearchVectorInput()` short-circuit at its existing
`$query->getOption('vector_input', FALSE)` check (see
`ai_search/src/Plugin/search_api/backend/SearchApiAiSearchBackend.php`,
~line 918) and skip calling OpenAI entirely on cache hits.
## Suggested approach
1. Inject a cache backend (e.g. `cache.default` or a dedicated
`cache.ai_related_content` bin) and the `ai.provider` service
(`Drupal\ai\AiProviderPluginManager`) into `AIRelatedContentNodeFilter`.
2. In the Pinecone branch of `getVectorFromNode()`, build a cache ID from the
embeddings provider + model, e.g.
`ai_related_content:pinecone_title_vector:{$provider_id}:{$model_id}`
(derive `$provider_id`/`$model_id` from
`$backend_config['embeddings_engine']`, same as
`SearchApiAiSearchBackend::getSearchVectorInput()` does).
3. On cache hit: call `$query->setOption('vector_input', $cached_vector)`
instead of `$query->keys('Title')`.
4. On cache miss: generate the embedding directly via
`$ai_provider_manager->createInstance($provider_id)->embeddings(new EmbeddingsInput('Title'), $model_id, ['skip_moderation'])->getNormalized()`,
store it in cache (permanent TTL is fine — the input string and model are
both fixed; invalidate by deleting the cache entry if the site ever changes
`embeddings_engine`), and use it the same way as the cache-hit path.
5. Leave the non-Pinecone path untouched — this only affects the
Pinecone-specific workaround.
## Notes
- Passing `['skip_moderation']` when generating this one-off embedding avoids
running the literal word "Title" through OpenAI moderation, which is
pointless for a constant, non-user-supplied string.
- No cache invalidation hook is strictly required since the cache ID is
derived from the provider/model config itself — changing the embeddings
engine naturally produces a new cache ID rather than serving a stale vector.
- Verify Pinecone still returns correct results when the injected vector is
identical across requests (it should — the actual match is driven entirely
by the `nid` metadata filter, the vector is only there to satisfy Pinecone's
API requirement).
issue