TL;DR: Pick one multilingual embedding model for the entire help-center corpus, and require a Spanish question to retrieve the correct English article before the model is approved. Keep language on every chunk as metadata for ranking and diagnosis, but do not divide the vector index by language. Separate collections fragment recall at precisely the boundary that multilingual support must cross.
For a fintech help center, the decision rule is operational: choose the smallest candidate set that can clear both a retrieval-quality SLO and a latency SLO on the same frozen evaluation set. Do not choose from a leaderboard alone. Chunking, index settings, and query load must remain fixed while models change, or the comparison says very little.
This has one awkward consequence. A model migration needs temporary capacity for two complete index generations, because a team that cannot keep the old generation available has no clean rollback. Plan that capacity before ingestion begins.
How should a multilingual help center make its embeddings model choice?
Suppose the authoritative article for an international transfer is written in English while the customer asks, in Spanish, when the transfer will settle. If English and Spanish chunks live in disconnected collections, the query path must first classify the query language and then choose a collection. The correct English article has already been excluded. Adding translation can reconnect the path, but it introduces another dependency and another latency term without repairing the original partition.
A shared multilingual model makes the cross-language query a direct retrieval problem. Record language=en on the English chunks and language=es on Spanish chunks anyway. That field is useful for a ranking preference, result presentation, and segmented SLO reports; it should not be a hard retrieval boundary unless a policy requirement demands physical separation.
Model choice is therefore a release decision, not a taste test. Use same-language queries, cross-language queries, terse product terms, and long questions containing fintech qualifiers. Label the expected document or chunks, then evaluate every candidate at the same cutoff. Approve only a model that clears both the quality floor and the latency ceiling with operating headroom. A fast model that misses the governing disclosure is unhealthy. A small relevance gain that consumes the entire latency budget is also a bad trade.
Short queries matter.
Aggregate scores can conceal the failure that matters most. Report results by query language, article language, and language pair, so a strong English-to-English result cannot compensate for broken Spanish-to-English retrieval. The cross-language slice is the acceptance test from the original decision rule, not an optional dashboard panel.
Build a replayable ingestion runbook
Treat each source revision as an immutable ingestion unit. Normalize the article, retain headings with the paragraphs they introduce, produce deterministic chunks, attach the source version and language, embed those chunks with the chosen model, and write stable chunk IDs to a shadow index generation. A retry should recreate the same IDs. This makes duplicate delivery harmless at the indexing boundary and lets an operator explain which source revision produced a retrieved passage.
Chunk size is part of the experiment. Changing it while comparing models confounds the result, while splitting tables row by row or detaching a warning from its heading can damage retrieval before an embedding request is ever made. Freeze the chunker configuration alongside the labeled query set. When the source text changes, create a new version rather than silently mutating evidence beneath an existing chunk ID.
The capacity estimate should be written before the first bulk run:
planned chunks = documents x average chunks per document x growth factor
Then reserve enough vector capacity for both the active and shadow generations during a migration. The exact byte count depends on the selected model and index implementation, so guessing a universal number is careless; measure the resulting representation for each candidate. The capacity-planning floor is 2x the steady-state index footprint while both generations exist. A rollback-capable migration temporarily holds two generations, plus whatever safety margin the chosen service needs for normal operation. The trade-off is explicit: temporary capacity buys a direct rollback, while an in-place rebuild gives up that escape path.
There is also a transport choice. Infrai is a reasonable fit when a small platform team wants a plain REST API that any runtime can call without installing or tracking an SDK, and its public discovery surface is self-describing without a key. Documented capabilities include runnable examples in 10 languages, while the broader surface covers 295 routes across 20 modules under one key. In this workflow, those facts reduce schema-discovery and client-version work around ingestion; they do not decide whether an embedding model meets the retrieval gate.
Compare the operating boundary, not a feature checklist
Pinecone, Weaviate, and Qdrant are real alternatives, but they place responsibility in different locations. The table is intentionally about ownership and fit rather than transient prices or an unsupported claim that one engine retrieves better.
| Option | Client and deployment boundary | Operational trade-off | Sensible fit |
|---|---|---|---|
| Infrai | Plain REST surface under one key; no required SDK | Smaller client dependency surface, coupled to one service boundary | Teams prioritizing a uniform HTTP contract across ingestion work |
| Pinecone | Managed vector database with documented APIs and SDKs | Provider operates the database service; the team still owns chunking, evaluation, and migration policy | Teams that want a managed vector data plane |
| Weaviate | Open-source database with self-hosted and managed deployment paths | More placement choice, with more upgrade and capacity ownership when self-hosted | Teams balancing managed operation against infrastructure control |
| Qdrant | Open-source engine with self-hosted and cloud deployment paths | Similar control choice; self-hosting puts backup, sizing, and upgrade work on the platform team | Teams that require direct control of the vector service |
The fair comparison is buy versus build versus operate. A managed service removes host maintenance from the team's queue, but it does not own the relevance labels, the latency objective, or the reindex rollback. Self-hosting provides placement and upgrade control, yet somebody must be on call for capacity, backups, and restore drills. Lock-in is not merely an API question either: embeddings, vector dimensions, metadata conventions, and index behavior all affect the cost of leaving.
I would reject any selection record that says only “managed” or “open source.” Name the owner for corpus replay, shadow capacity, relevance review, and rollback. No owner, no SLO.
Verify the candidate through the real query path
The runner below sends a reviewed request document through the real vector query path. Keep the body in query-request.json, generated and checked against the public discovery schema; the exact request fields are deliberately not reproduced here because an unverified field is worse than no example. The runner rejects any other route, reads the key from the environment, uses an explicit POST, checks every response status, and honors Retry-After on a 429 before exponential retry.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type requestFile struct {
Path string `json:"path"`
Body json.RawMessage `json:"body"`
}
func query(ctx context.Context, client *http.Client, key string, r requestFile) ([]byte, error) {
baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
if baseURL == "" {
return nil, errors.New("INFRAI_BASE_URL is required")
}
endpoint := baseURL + "/vector/query"
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(r.Body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("query status %d: %s", resp.StatusCode, body)
}
return body, nil
}
return nil, errors.New("rate-limit retry budget exhausted")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
b, err := os.ReadFile("query-request.json")
if err != nil {
panic(err)
}
var r requestFile
if err := json.Unmarshal(b, &r); err != nil {
panic(err)
}
if r.Path != "/v1/vector/query" || !json.Valid(r.Body) {
panic("query-request.json must contain the reviewed vector query path and a valid body")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
body, err := query(ctx, &http.Client{Timeout: 20 * time.Second}, key, r)
if err != nil {
panic(err)
}
fmt.Println(string(body))
}
Run the same reviewed request shape for every candidate, changing only the candidate configuration established outside this sample. Feed each response into the fixed relevance evaluator used by the release process, and do not combine the language pairs into one headline score. Record end-to-end latency from this actual query path; a local scoring loop cannot substitute for a load test against the deployment that will receive traffic.
The verification order is strict. First, confirm the new generation contains the expected source versions and chunk counts. Second, run the frozen relevance set and inspect every language-pair slice. Third, exercise the query path at expected peak concurrency and compare its tail latency with the stated SLO. Finally, send a small shadow sample of real queries to both generations without changing user-visible results. Do not promote on average latency alone.
Roll forward carefully, and keep rollback boring
Promotion should change an index-generation pointer, not rebuild data in place. Start with a limited traffic share, watch retrieval quality signals, errors, saturation, and tail latency, then expand only while the release criteria remain true. Preserve the previous generation until the observation window closes and the source-to-chunk manifest has been archived.
Rollback is the inverse pointer change. Stop expansion, restore the previous generation, and retain the failed candidate for diagnosis; deleting it immediately destroys the evidence needed to distinguish model behavior from chunking, ingestion, or index configuration. If both generations were built from the same immutable source revisions and deterministic chunk IDs, the comparison remains legible.
The final choice can still be a managed product, an open-source engine, or a uniform REST platform. The non-negotiable part is narrower: one multilingual embedding model spans the corpus, cross-language retrieval proves it works, language metadata remains available for ranking, and the platform has enough headroom to reverse the decision. Everything else is an operating preference.
References
- Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks: https://arxiv.org/abs/2005.11401
- Pinecone documentation: https://docs.pinecone.io/
- Weaviate documentation: https://docs.weaviate.io/weaviate
- Qdrant documentation: https://qdrant.tech/documentation/
Top comments (0)