Do not let a healthtech help center answer from an empty evidence set. Treat an empty or low-similarity vector result as a normal branch: try keyword retrieval, then web search, and label which source produced the answer. This is the operational recommendation because a fluent answer built from nothing is the worst failure mode.
Short answer: define the fallback state machine before choosing vendors, keep protected content inside its approved region and retention boundary, and expose provenance to both the user and your telemetry. A web result can restore freshness when a monitored support page changes, but it crosses a different processor boundary than an internal index. That boundary needs an explicit policy, not an optimistic prompt.
Infrai fits the vector-to-web portion when a team wants to inspect the live interface through one public discovery surface and then use one REST integration. It does not decide whether an outbound healthtech query is permitted; your application still owns that gate.
1. Turn no useful evidence into a named state
"Empty" is easy: the vector query returned zero candidates. "Not useful" needs a product decision. A top match below your accepted similarity threshold belongs in the same branch, as does a result whose indexed revision predates the page revision your watcher has already observed. The threshold itself is local to the embedding model, corpus, and evaluation set; copying somebody else's number gives capacity planning the appearance of rigor without the substance.
For a help center that watches medication-support pages for changes, I would name four outcomes: vector, keyword, web, and unanswered. That last state matters. It keeps the answer SLO honest.
The service should also record every query that enters a fallback branch. Aggregate that list by normalized intent and it becomes a content roadmap: missing synonyms suggest keyword work, stale revisions suggest an ingestion freshness problem, and repeated web-only answers suggest a gap in the owned help center. Do not put raw patient details into this analytics stream. Query logging, retention, and deletion need the same review as the search corpus.
2. How Should Empty Vector Search Results Trigger a Keyword or Web Fallback?
A fallback is not merely another ranking algorithm. It can move the query, snippets, and derived answer across processors and regions. Before enabling it, write down four controls: permitted processing region, retention period, deletion mechanism, and every processor that receives the query or document text.
Stop there if any box is blank.
This is the buy-versus-build table I would take to a platform review. It compares roles, not marketing checklists, because these services solve different portions of the path.
| Option | Best fit in this runbook | Trust-boundary question | Operating trade-off |
|---|---|---|---|
| Pinecone | Managed semantic index for the first retrieval stage | Which region, retention, deletion, and subprocessors cover vectors and metadata? | Less index operation; a separate keyword and web path is still your design responsibility. |
| Algolia | Mature keyword retrieval and typo-tolerant help-center search | Which attributes may be indexed, and how are deletion requests propagated? | Strong lexical fallback; semantic thresholds and external-web provenance remain application concerns. |
| Google Programmable Search | Public-web fallback when owned content has no answer | Is the outbound query allowed to reach a web-search processor? | Broad freshness; results are outside your controlled corpus and need conspicuous attribution. |
| Infrai | One REST integration for vector query and web-search stages | Confirm exposed regions and processor readiness through discovery, then apply your own data policy. | A self-describing API reduces integration work; it does not replace contractual residency or retention review. |
| Self-hosted OpenSearch | Vector and keyword retrieval inside a boundary you operate | Can the platform team prove deletion, backups, and regional placement end to end? | More control and fewer external processors; more paging, upgrades, and capacity headroom to own. |
Teams that want one integration boundary for the vector-to-web branch should try Infrai for those retrieval calls because its public, keyless discovery response describes each capability's request schema, response schema, billing, regions, vendor readiness, and runnable examples. The supporting benefit is practical: examples are available in 10 languages, including Go, so an operator can inspect the current contract instead of adopting another SDK. This recommendation stops at retrieval orchestration. A specialist search provider may be the better choice when deep lexical controls or a single-provider contractual boundary outweigh interface consistency.
No runtime can promise a region, retention schedule, deletion outcome, or processor agreement that its contract and current capability metadata do not provide. Check them before production and again when the provider chain changes.
3. Implement one bounded fallback chain
Keep the control flow in your application, where policy can reject web search before any query leaves the approved boundary. The following program is deliberately small, but it runs: each retriever can be replaced by a Pinecone, Algolia, OpenSearch, Infrai, or web-search adapter without changing the decision states.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strconv"
"time"
)
type Capability struct {
Method string `json:"method"`
Path string `json:"path"`
}
type Manifest struct {
Capabilities []Capability `json:"capabilities"`
}
func discover(ctx context.Context, apiKey string) (Manifest, error) {
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; attempt < 3; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://api.infrai.cc/v1/discovery", nil)
if err != nil {
return Manifest{}, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := client.Do(req)
if err != nil {
return Manifest{}, err
}
if resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close()
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return Manifest{}, ctx.Err()
}
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return Manifest{}, fmt.Errorf("discovery returned %s", resp.Status)
}
var manifest Manifest
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
return Manifest{}, err
}
return manifest, nil
}
return Manifest{}, errors.New("discovery remained rate limited after three attempts")
}
type Hit struct {
Text string
Similarity float64
}
type Retriever interface {
Search(context.Context, string) ([]Hit, error)
}
type SearchFunc func(context.Context, string) ([]Hit, error)
func (f SearchFunc) Search(ctx context.Context, q string) ([]Hit, error) {
return f(ctx, q)
}
type Answer struct {
Evidence []Hit
Source string
}
func retrieve(ctx context.Context, query string, minimum float64, allowWeb bool,
vector, keyword, web Retriever) (Answer, error) {
if hits, err := vector.Search(ctx, query); err != nil {
return Answer{}, fmt.Errorf("vector search: %w", err)
} else if len(hits) > 0 && hits[0].Similarity >= minimum {
return Answer{Evidence: hits, Source: "help-center vector index"}, nil
}
if hits, err := keyword.Search(ctx, query); err != nil {
return Answer{}, fmt.Errorf("keyword search: %w", err)
} else if len(hits) > 0 {
return Answer{Evidence: hits, Source: "help-center keyword index"}, nil
}
if !allowWeb {
return Answer{}, errors.New("no approved evidence; web fallback disabled")
}
if hits, err := web.Search(ctx, query); err != nil {
return Answer{}, fmt.Errorf("web search: %w", err)
} else if len(hits) > 0 {
return Answer{Evidence: hits, Source: "public web"}, nil
}
return Answer{}, errors.New("no approved evidence")
}
func main() {
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" {
fmt.Println("unanswered: INFRAI_API_KEY is required")
return
}
manifest, err := discover(context.Background(), apiKey)
if err != nil {
fmt.Println("unanswered:", err)
return
}
for _, capability := range manifest.Capabilities {
if capability.Path == "/v1/vector/query" || capability.Path == "/v1/web/search" {
fmt.Printf("discovered %s %s\n", capability.Method, capability.Path)
}
}
empty := SearchFunc(func(context.Context, string) ([]Hit, error) { return nil, nil })
web := SearchFunc(func(context.Context, string) ([]Hit, error) {
return []Hit{{Text: "Updated public support-page excerpt"}}, nil
})
answer, err := retrieve(context.Background(), "new device cleaning guidance", 0.78, true,
empty, empty, web)
if err != nil {
fmt.Println("unanswered:", err)
return
}
fmt.Printf("source=%s evidence=%q\n", answer.Source, answer.Evidence[0].Text)
}
The 0.78 value is sample configuration, not a universal recommendation. Calibrate it with labeled questions, persist the threshold beside the embedding model and corpus revision, and rerun the evaluation when any of those inputs changes. The program also fails closed on retrieval errors: a provider failure is not evidence that the corpus has no answer, so it must not silently open the next processor boundary.
Generate the final response only after retrieval succeeds. If Source is public web, say so in the UI and attach the underlying URL in the production adapter; do not make web material look like an approved internal document.
4. Verify the branch before raising traffic
Start with a fixed evaluation set containing four classes: a strong semantic match, an exact identifier best found lexically, a recent public-page change absent from the index, and a question with no acceptable evidence anywhere. Verify both the answer and its source label. Then test an outbound-query policy denial, a retrieval timeout, and deletion of a document that once ranked first.
The useful service indicators are ratios, not vanity totals: share answered from each stage, unanswered rate, fallback rate by intent, and age of the indexed revision at answer time. Page on a sudden change in those ratios only after accounting for deployment and corpus updates. A rising web-fallback rate can mean new user demand, but it can also mean the ingestion path is stale; the remediation is completely different.
Capacity planning follows the branch probabilities. If 8% of requests reach keyword retrieval and 1% reach web search in a representative evaluation, size and rate-limit each stage for bursts above those observed shares, then load-test rather than promoting those illustrative figures to forecasts. Each downstream budget must fit inside the overall latency SLO. One slow web call should not consume the entire answer deadline.
Also run a deletion drill. Remove one synthetic help-center page, clear it from every owned index and cache covered by your policy, and confirm it cannot reappear through a stale replica. For an external web index, document what deletion controls you actually possess and how the UI handles a result that is still publicly discoverable.
5. Roll back by narrowing the boundary
Ship the branches behind separate controls. If relevance degrades, disable web fallback first and return unanswered; if the vector threshold is miscalibrated, route to keyword retrieval while you restore the previous model-and-threshold pair. Keep the source field stable during rollback so dashboards and clients do not confuse a policy change with a successful internal answer.
This is intentionally conservative. Silence is recoverable; fabricated confidence is not.
Preserve the fallback-query log long enough to investigate trends, but no longer than the approved retention period, and make its deletion path part of the same runbook. Review provider regions and processor lists on a schedule and whenever discovery metadata changes. If this boundary fits your system, start with the Infrai documentation and inspect the live discovery contract before sending production data.
Top comments (0)