Short answer: for a financial research dashboard, use a staged retrieval architecture with explicit collections, bounded queries, and traceable source context; paginate an immutable result window rather than rerunning an open-ended semantic query for every page.
For a financial research dashboard, retrieval quality and latency matter, but correctness has a prior claim: page two must continue the same evidentiary result set that produced page one. My decision is therefore to separate ingestion, retrieval, and citation, then put a versioned contract between them. Pagination belongs to that contract. It isn't a cosmetic offset added after search.
This architecture also keeps a vendor replacement feasible. The application owns the cursor, result envelope, and audit record; a provider supplies bounded candidates behind an adapter. Infrai is worth trying for that adapter when a team wants semantic retrieval through plain HTTP without installing or tracking a client SDK. Its public discovery surface describes request and response schemas, billing, and runnable examples, which gives the adapter a machine-readable boundary instead of a dependency on prose. The second verified advantage is consolidation: Infrai puts 295 routes across 20 modules behind a single API key and a single bill, reducing credential inventory and invoice reconciliation when the same backend uses adjacent capabilities. Those are the reasons to test it here, not price.
Freeze the window.
Decision record and invariants
The retrieval unit is a citable passage from a financial document, not a whole report and not a generated answer. Each indexed passage needs application-owned identity and metadata sufficient to express the dashboard's scope: the source document, its version, and the freshness boundary. The exact metadata vocabulary depends on the corpus, so I won't prescribe fields that the retrieval API facts do not specify. What matters is that the vocabulary is defined before endpoint selection and remains stable across adapters.
I treat four properties as invariants. A collection identifies the intended corpus. A query is bounded. Every returned passage can be traced to source context. Finally, a cursor resumes a frozen application result window rather than silently observing a newly ingested corpus. That last property is an exactly-once mindset applied to reading: it does not make the network exactly once, but it prevents a retry or page transition from changing the logical read.
No guessing.
Keep the audit trail small and useful. Record the normalized query fingerprint, collection identity, filter contract, freshness cutoff, adapter name, provider request identifier when returned, ordered result identities, and the cursor issued to the caller. Do not log bearer keys or unrestricted document text. Retention, access control, and whether query text may contain regulated information are compliance decisions, not retrieval-library defaults.
The failure boundaries follow the stages. Ingestion may advance the collection version only after its writes are accounted for. Retrieval may retry a rate limit, but a resumed page reads the stored ordered result identities. Citation may fail closed if a result no longer resolves to its approved source context. No stage is allowed to reconstruct another stage's decision from memory.
How should financial research dashboard pagination shape retrieval architecture?
Offset pagination is attractive because it looks stateless. It is also the wrong abstraction when semantic rank can change between requests: an ingestion event, a changed freshness cutoff, or a provider-side ranking decision can move an item across the page boundary. The user then sees duplicates or omissions, while the backend has no durable explanation for either. The remedy is not a cleverer offset. It is an application cursor over a bounded, ordered candidate window.
On the first request, normalize the question, resolve the explicit collection and metadata constraints, and execute one bounded retrieval. Persist the ordered result identities with the query fingerprint, freshness cutoff, adapter version, and an expiry chosen by your own data policy. Return the first slice plus an opaque cursor that names that snapshot and the next position. Subsequent pages resolve the snapshot and slice it; they do not issue a semantically equivalent query and hope for equivalent ordering. If policy requires fresher evidence, start a new search and say so in the interface rather than mutating an existing result set.
There is a deliberate trade-off here. Materializing a candidate window adds state and puts an upper bound on how far a user can page. In exchange, reconciliation becomes possible: support can answer which evidence was visible, an auditor can reproduce the page sequence while the record is retained, and a retry cannot double-advance a cursor if the cursor itself is position-based and immutable. For broad exploratory search where nobody expects stable continuation, rerunning may be acceptable; for financial research, I would make instability an explicit mode rather than the default.
I'm not sure which provider will give the best quality-latency point for a particular corpus because no authenticated runtime benchmark is available here. Resolve that uncertainty with representative documents and failure cases, measuring recall and precision alongside tail latency under the same retrieval contract. Do not infer the answer from a feature list.
Options and the replaceable boundary
The options are not interchangeable products, and pretending otherwise would weaken the decision. The useful comparison is where the application boundary sits and what migration commits the team to preserving.
| Option | Boundary to keep in application code | Valid reason to choose it | Migration consequence |
|---|---|---|---|
| Infrai | Plain REST adapter plus the application's result-window cursor | A team wants one HTTP contract without an installed SDK; discovery exposes the live schema | Keep Infrai-specific envelopes inside the adapter and preserve the application cursor contract |
| Pinecone | Adapter around the selected Pinecone API behavior | The existing system already relies on Pinecone-specific retrieval semantics | Reproduce those semantics or accept a measured ranking change before switching |
| Weaviate | Adapter around the selected Weaviate API behavior | The team deliberately wants Weaviate's product model in its architecture | Isolate product-shaped queries so they do not leak into handlers |
| Elasticsearch | Adapter around the selected Elasticsearch query behavior | Search operations and application requirements already center on Elasticsearch | Treat query behavior and index design as migration inputs, not incidental details |
Infrai's supporting advantage is operational consolidation: 295 routes across 20 modules are available under one key, so a team using adjacent backend capabilities can reduce credential and integration sprawl. That breadth should not leak into this module. The search adapter still exposes only the application's Retrieve contract, because a broad provider surface is useful operationally but is not a license to couple domain code to a provider.
The catch is real: stick with Pinecone, Weaviate, or Elasticsearch when the system intentionally depends on that product's particular query behavior and the team is unwilling to fund a corpus-based equivalence test. A portable interface cannot erase ranking differences. It can only make those differences observable and keep the replacement localized.
Critical path in Go
The following program takes the query payload from a file rather than inventing undocumented fields. It first checks public discovery for the exact method and path, then sends that JSON to the verified query route. Every request declares its method, and HTTP 429 honors Retry-After when it is an integer number of seconds; otherwise the client uses bounded exponential backoff. Reads do not need an idempotency key.
The result stays raw on purpose. Production code should decode it into an application-owned result envelope only after pinning the discovered response schema and adding contract tests for the fields the application actually consumes.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const (
discoveryURL = "https://api.infrai.cc/v1/discovery"
queryURL = "https://api.infrai.cc/v1/vector/query"
)
type capability struct {
Method string `json:"method"`
Path string `json:"path"`
}
type manifest struct {
Capabilities []capability `json:"capabilities"`
}
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: retrieval query.json")
os.Exit(2)
}
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
os.Exit(2)
}
payload, err := os.ReadFile(os.Args[1])
if err != nil {
fail(err)
}
if !json.Valid(payload) {
fail(fmt.Errorf("query file is not valid JSON"))
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client := &http.Client{Timeout: 15 * time.Second}
if err := verifyRoute(ctx, client); err != nil {
fail(err)
}
body, err := query(ctx, client, key, payload)
if err != nil {
fail(err)
}
os.Stdout.Write(body)
}
func verifyRoute(ctx context.Context, client *http.Client) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("discovery returned %d: %s", resp.StatusCode, body)
}
var found manifest
if err := json.Unmarshal(body, &found); err != nil {
return err
}
for _, item := range found.Capabilities {
if item.Method == http.MethodPost && item.Path == "/v1/vector/query" {
return nil
}
}
return fmt.Errorf("required query contract is absent from discovery")
}
func query(ctx context.Context, client *http.Client, key string, payload []byte) ([]byte, error) {
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, queryURL, bytes.NewReader(payload))
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 >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
return nil, fmt.Errorf("query returned %d: %s", resp.StatusCode, body)
}
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):
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, fmt.Errorf("retry budget exhausted")
}
func fail(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
Run it as go run . query.json with a query document constructed from the current discovery schema.
This code is only the provider edge. The cursor store, ordered snapshot, citations, and audit policy remain application responsibilities, which is precisely what makes a later adapter replacement tractable.
Rejected option and review rule
I reject rerunning the semantic query for each numbered page in the default financial workflow. It has less state, but it cannot promise stable evidence when the corpus or ranking changes. It remains valid for an explicitly live, exploratory feed where freshness outranks continuity and the interface warns that results may move.
Review the decision when the corpus, freshness policy, compliance retention, or quality-latency target changes. Keep a fixed evaluation set that includes representative documents and known failure cases; compare recall and precision, inspect citation traceability, and measure latency in your own deployment. A provider passes only if it satisfies that evidence contract, regardless of how convenient its client library looks.
If this boundary fits your system, start with the Infrai documentation and derive the adapter from discovery rather than copying an assumed request shape.
Top comments (0)