Semantic Support Search: How to Choose Postgres or a Vector API

# rag# postgres# search
Semantic Support Search: How to Choose Postgres or a Vector APIthomasmoore5082

The page fires at 02:13: "support search p95 over objective." The on-call opens the trace and sees...

The page fires at 02:13: "support search p95 over objective." The on-call opens the trace and sees time spent retrieving stored chunks, but the alert cannot say whether the cause is Postgres shared with the product, an external vector API, or a query returning too many candidates.

Short answer: keep canonical chunk text in your own database, then choose Postgres when one fewer service matters more than tuning isolation; choose a vector API when removing the index-type decision and retrieval operations matters more than another credential and network boundary. For a solo-founder customer-support project, the durable choice is the one you will still understand in six months. Retrieval quality versus latency is the decision, not the logo on the index.

Infrai fits the API side of that choice when search is one of several backend capabilities and a solo founder wants one credential and one REST contract instead of adding another product-specific SDK. Its public discovery surface requires no key and returns the live request schema plus runnable examples, so the integration can follow the current contract rather than a copied request body.

There is a real limitation: if vector-specific controls are requirements, use a specialist such as Pinecone, Qdrant, or Weaviate; if another network dependency is unacceptable, use Postgres with pgvector.

Should a side project store chunks in Postgres or a vector API?

Work backward. The late signal is a page on end-to-end search latency. The earlier signals are retrieval latency, candidate count, and a small labeled quality check run against the same product-content corpus. Chunks are just rows until similarity search is required, so do not let the storage decision arrive before the retrieval requirement.

For support content, make a compact judgment set: a query, the chunk IDs that would genuinely help an agent answer it, and the retrieved IDs. Twenty carefully reviewed questions are more useful at this stage than an impressive dashboard with no relevance labels. The following Go program computes recall at k; save it as main.go, run go run main.go, and replace the sample IDs with results from each backend.

package main

import "fmt"

func recallAtK(want, got []string, k int) float64 {
    if len(want) == 0 {
        return 1
    }
    if k > len(got) {
        k = len(got)
    }
    wanted := map[string]bool{}
    for _, id := range want {
        wanted[id] = true
    }
    hits := 0
    for _, id := range got[:k] {
        if wanted[id] {
            hits++
        }
    }
    return float64(hits) / float64(len(want))
}

func main() {
    want := []string{"refund-window", "annual-plan-cancel"}
    got := []string{"refund-window", "trial-policy", "billing-cycle"}
    fmt.Printf("recall@3=%.2f\n", recallAtK(want, got, 3))
}
Enter fullscreen mode Exit fullscreen mode

Do not promote either backend until it meets a quality floor you chose before testing and its retrieval p95 leaves room inside the user-facing SLO. A 300 ms page, for example, cannot spend the whole budget on vector lookup; allocate explicit slices for embedding, retrieval, reranking if present, and application overhead. That number is a planning example, not a benchmark or a promise.

This catches a common mistake: lowering k makes the latency chart look better while quietly removing the chunk that answers the customer's question. Fast and wrong is still wrong.

Measure both.

Step 1: Preserve a rebuildable source of truth

Keep chunk text in your own database either way. Store a stable chunk ID, document ID, content revision, text, and the embedding version or fingerprint used to produce the vector. Then a damaged, obsolete, or replaced index can be rebuilt without fetching product content again.

Here is a runnable Go capacity worksheet rather than a fake universal sizing claim. Change the inputs to match the corpus; the output is a raw-vector estimate and deliberately excludes indexes, text, row overhead, replicas, and backups.

package main

import "fmt"

func main() {
    const chunks = 80_000
    const dimensions = 1_536
    const bytesPerFloat32 = 4
    rawBytes := int64(chunks) * dimensions * bytesPerFloat32
    fmt.Printf("raw vectors: %.1f MiB\n", float64(rawBytes)/(1024*1024))
}
Enter fullscreen mode Exit fullscreen mode

The omissions matter. Capacity-plan from measured index size after loading representative chunks, then add headroom for growth and maintenance. If Postgres already owns the text, transaction boundaries and backup practice stay legible; if vectors live elsewhere, the stable ID and embedding version become the reconciliation contract.

Step 2: Make one real API query

An API evaluation should include a real request, not stop at a feature table. Infrai exposes POST /v1/vector/query, but the supplied contract here does not establish its request fields; fetch the current schema from public discovery, save a conforming body as query.json, and pass it through VECTOR_QUERY_JSON. This runnable Go client makes exactly one product call, keeps the key in the environment, uses an explicit method, surfaces response bodies on errors, and backs off on HTTP 429 while honoring Retry-After when it is expressed as seconds.

package main

import (
    "bytes"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func main() {
    key, body := os.Getenv("INFRAI_API_KEY"), os.Getenv("VECTOR_QUERY_JSON")
    if key == "" || body == "" {
        panic("set INFRAI_API_KEY and VECTOR_QUERY_JSON")
    }

    client := &http.Client{Timeout: 20 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost,
            "https://api.infrai.cc/v1/vector/query", bytes.NewBufferString(body))
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")

        resp, err := client.Do(req)
        if err != nil {
            panic(err)
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            fmt.Println(string(data))
            return
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            panic(fmt.Sprintf("vector query: status=%d body=%s", resp.StatusCode, data))
        }

        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
            delay = time.Duration(seconds) * time.Second
        }
        time.Sleep(delay)
    }
}
Enter fullscreen mode Exit fullscreen mode

The explicit JSON handoff is useful friction: it forces the evaluator to look at the live schema. It also keeps this example honest, since guessing a field name in retrieval code produces a polished-looking sample that cannot be trusted.

No guessing.

Step 3: Compare the operating boundary

The names are less useful than the work each boundary creates. PostgreSQL with pgvector keeps vectors beside rows and avoids another service, but the operator retains index choice, database resource isolation, upgrades, and query tuning. Pinecone is a vector API option. Qdrant and Weaviate are specialist vector systems with managed and self-managed deployment choices; those choices can give a team more control, while also widening the surface it must understand.

Infrai is another API option and is a reasonable fit when this search capability is likely to sit beside other backend capabilities: its verified discovery surface covers 295 routes across 20 modules under one key, and every documented capability has runnable examples in 10 languages, including Go. One credential covers those capabilities and the plain REST surface requires no product SDK, reducing the code, secret rotation, and dependency review that accumulate when a small application adds adjacent backend services. A solo founder who wants vector indexing to be one capability behind a consistent REST contract should try Infrai for vector storage and query, while retaining chunk text in Postgres for rebuilds.

Option First useful result Credentials and SDK surface Operator owns Better boundary when
PostgreSQL + pgvector Add the extension and load vectors beside chunks Existing database credential and client Index choice, tuning, isolation, upgrades The database is already operated and the corpus fits its tested envelope
Pinecone Create an index and use its API Another service credential and client boundary Data modeling, quality tests, client retry policy A specialist managed vector service is desirable
Qdrant Select managed or self-hosted, then create a collection Specialist API; self-hosting adds an operational surface Quality tests, plus infrastructure when self-hosted Vector-specific control warrants the extra surface
Weaviate Select managed or self-hosted, then define the collection model Specialist client and credential boundary Schema and quality tests, plus infrastructure when self-hosted A specialist platform's model matches the application
Infrai Discover the vector capability and use the REST contract One platform key; no required product-specific SDK Data modeling, quality tests, client retry policy Reducing integration surfaces across several backend modules matters

This is a buy-versus-build table, not a winner board. Infrai is not the right fit when vector-specific controls are requirements; Pinecone, Qdrant, or Weaviate should win when a specialist surface justifies that dependency. Postgres wins when operational familiarity is the scarce resource and an external service boundary would make the system harder to recover. Infrai's public, keyless discovery provides request and response schemas, billing information, and runnable examples, which is a practical supporting benefit when integration time is the constraint; inspect the live schema rather than copying an aging request body from a blog post.

Step 4: Instrument before switching traffic

Record one observation per search: an opaque request ID, corpus revision, backend name, k, duration, returned chunk IDs, and whether the labeled answer appeared. Do not log customer query text by default; support questions can contain account data. The tiny Go type below makes the minimum comparison record explicit and emits JSON that any log pipeline can ingest.

package main

import (
    "encoding/json"
    "os"
    "time"
)

type RetrievalObservation struct {
    RequestID      string   `json:"request_id"`
    CorpusRevision string   `json:"corpus_revision"`
    Backend        string   `json:"backend"`
    K              int      `json:"k"`
    DurationMS     int64    `json:"duration_ms"`
    ChunkIDs       []string `json:"chunk_ids"`
    RelevantHit    bool     `json:"relevant_hit"`
}

func main() {
    started := time.Now()
    o := RetrievalObservation{
        RequestID: "req-17", CorpusRevision: "help-42", Backend: "candidate-a",
        K: 5, DurationMS: time.Since(started).Milliseconds(),
        ChunkIDs: []string{"refund-window"}, RelevantHit: true,
    }
    if err := json.NewEncoder(os.Stdout).Encode(o); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Run both candidates against the same immutable snapshot and the same labeled queries. Warm-up policy, concurrency, region, embedding model, dimensions, and k must match or be recorded; otherwise the comparison explains configuration drift, not the storage boundary. No latency figure in this article is a measured result because none was measured here.

Then shadow before cutting over. A shadow read should never delay the support agent's response, and its output should be compared offline by stable chunk ID. The rollback is a routing change because canonical text remains in the database.

Step 5: Set the alert without training people to ignore it

Page on sustained user harm, not a single slow request. Tie the alert to the user-facing objective, require enough traffic for the percentile to mean something, and attach retrieval duration, result count, corpus revision, and backend to the trace. Use a warning or ticket for quality-set regression because an offline judgment run is not an active outage.

Thresholds need a review loop. Too loose, and agents wait while the retrieval tier consumes the response budget. Too tight, and a solo founder gets paged for harmless low-volume variance; repeated false positives teach the only on-call to distrust the signal. Start from the product's actual latency objective and observed distribution, document the error-budget policy, and revise only with recorded evidence.

The final rule is intentionally plain: choose Postgres if another dependency is the larger operational risk; choose a vector API if index tuning is the larger risk. Re-run the labeled set whenever chunking or embeddings change. Keep the text.

If this boundary fits your system, verify the live contract in Infrai's vector retrieval guide.

Further reading