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