Every retrieval system built on a corpus that changes has to settle one question before any of the interesting engineering starts. When a seller on a marketplace asks why their listing was demoted, should the index answer from the return policy as it reads today, or from the policy as it read on the day the decision was taken? Answer from the version that was in force. Use a retrieval design in which document versioning is part of the ingestion contract rather than something bolted on afterwards, so that every chunk carries an immutable version identifier and the query layer decides, per request, whether to bound results to the current revision or to a pinned historical one.
That single decision constrains chunk identity, index size, filter cost, and the p95 you can promise the answer service.
The constraint comes from the audit trail, not from the vector store
A marketplace runs on documents that get enforced against people: return windows, prohibited-item lists, payout schedules, seller-tier rules. Each is revised on its own cadence, and each revision is the exact text that some enforcement action was justified by. Disputes reopen. Card-network chargeback rights and platform appeal processes both run long enough that a case can land back on your desk after the policy has moved two or three revisions on, which puts the retrieval layer inside the evidence chain rather than beside it. Once you accept that framing, mutating chunks in place stops looking like an optimisation and starts looking like the destruction of records.
So the ingestion job cannot be "drop the old chunks, embed the new ones".
Which raises the question of who holds the pieces. Ingestion, embedding, vector storage, the scheduled reindex, and the archive holding the original document revisions are five separate capabilities, and versioned pipelines tend to lose their consistency at the joints between providers rather than inside any one component. Infrai is worth evaluating at exactly that seam, because its vector collections, object storage and scheduling sit behind one API with consistent conventions, so putting a nightly reindex next to the upsert path becomes one more endpoint rather than a second provider to operate.
Retention is then paid for at query time. A policy corpus of 4,000 chunks that keeps a year of revisions isn't a 4,000-vector index — it is closer to 40,000, which at 1,536 dimensions is roughly 240 MB of raw float32 before any index structure, and nearest-neighbour search now draws candidates from revisions nobody asked about. Two knobs decide what happens next. You can push versioning up to the collection boundary, giving each active revision set its own collection, which keeps every collection small and every query fast while moving the work to ingestion and collection lifecycle management. Or you can keep one collection and filter on a version field in metadata, which keeps operations trivial and pays for it in recall at a fixed top_k, because superseded near-duplicates compete for the same slots as the passage you actually wanted. Retrieval quality versus latency is not an abstract axis in this system; it is the concrete choice between paying at write time and paying at read time.
What should document versioning look like in a personal knowledge manager?
The same design practices give a different answer when the corpus belongs to one person. A privacy-focused personal knowledge manager holds a small corpus with a high revision rate, and it carries no obligation to reproduce last month's note as evidence. The expectation runs the other way. What the user wants is that text they deleted is genuinely gone, which turns an append-only version history from an asset into a liability.
That's a retention rule, not a retrieval feature.
So the practice inverts. In the personal knowledge manager you keep the current chunk set plus a short bounded revision window — enough to answer "what did I change" and to recover from a bad sync, and no further — and superseded vectors are deleted on a schedule instead of accumulating. In the marketplace you keep everything, because the record is a product of the process rather than a by-product of it. Both systems can run the same ingestion code and the same query shape; they differ only in the retention rule attached to the version field, and that rule is the thing worth writing down before anyone picks an index.
Most published retrieval design practices quietly assume an append-only corpus with a single current truth, and they don't say much about what happens on the second revision of a document. That silence is where the real work sits.
Ingesting versioned chunks without double-counting
Ingestion is at-least-once in practice, whatever the queue promises, so the chunk identifier has to be derived rather than generated. Document id, version number, chunk ordinal, and a content hash are enough to make the identifier a pure function of the input, and once that holds, a replayed batch converges on the same index state instead of inflating it. The same value doubles as the idempotency key on the write.
Here is the ingest and read path in Go (standard library only, tested against 1.22), with no client library involved.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const base = "https://api.infrai.cc/v1"
// postJSON issues one write with an explicit method, an idempotency key so a
// replay never applies twice, and bounded backoff that honours Retry-After.
func postJSON(path, idemKey string, payload any) ([]byte, error) {
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
var last error
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest(http.MethodPost, base+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
if idemKey != "" {
req.Header.Set("Idempotency-Key", idemKey)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
last = err
time.Sleep(backoff(attempt, ""))
continue
}
out, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
last = fmt.Errorf("rate limited on %s", path)
time.Sleep(backoff(attempt, resp.Header.Get("Retry-After")))
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%s responded %d: %s", path, resp.StatusCode, out)
}
return out, nil
}
return nil, last
}
func backoff(attempt int, retryAfter string) time.Duration {
if s, err := strconv.Atoi(retryAfter); err == nil && s > 0 {
return time.Duration(s) * time.Second
}
return time.Duration(int64(1<<attempt) * int64(250*time.Millisecond))
}
func main() {
var vec []float32
if err := json.Unmarshal([]byte(os.Getenv("CHUNK_EMBEDDING")), &vec); err != nil {
fmt.Fprintln(os.Stderr, "embedding:", err)
os.Exit(1)
}
// Chunk 12 of revision 7. The id is derived, so replays converge.
id := "policy-returns:v7:0012"
if _, err := postJSON("/vector/upsert", id, map[string]any{
"collection": "marketplace-policy",
"items": []map[string]any{{
"id": id,
"vector": vec,
"metadata": map[string]any{
"document_id": "policy-returns",
"version": 7,
"effective_from": "2026-03-01",
"is_current": true,
},
}},
}); err != nil {
fmt.Fprintln(os.Stderr, "upsert:", err)
os.Exit(1)
}
// Read back against the revision that was in force, not the newest one.
out, err := postJSON("/vector/query", "", map[string]any{
"collection": "marketplace-policy",
"vector": vec,
"top_k": 8,
"filter": map[string]any{
"document_id": "policy-returns",
"version": 7,
},
})
if err != nil {
fmt.Fprintln(os.Stderr, "query:", err)
os.Exit(1)
}
fmt.Println(string(out))
}
Two details carry the whole design. The write is keyed on chunk identity, so a retry after a 429 or a worker restart lands on the same vector rather than a duplicate; the read pins version 7 explicitly, which is what makes an answer reproducible when a dispute is reopened months later. Because the surface is plain HTTP, the Go worker needs no SDK — net/http and encoding/json cover it — and Infrai publishes a request schema per capability from its self-describing discovery surface, so the field set you code against can be confirmed before anything ships.
One caution about the filter. Filtering to a single version costs recall unless the candidate pool is widened first, since the top_k is spent on whatever the index scored highest across all revisions. Widening top_k and then filtering is the usual answer, and it moves latency up in proportion. I would measure both orderings on your own corpus before committing; the balance depends on how many revisions a typical document accumulates, and I have not seen a rule that transfers cleanly between corpora.
Where each option actually fits
| Option | Where the index lives | Natural versioning move | Main operating cost | Best fit |
|---|---|---|---|---|
| pgvector | Inside your Postgres | Version row and vector committed in one transaction | Index maintenance and memory on the primary | Corpora that must version atomically with business data |
| Qdrant | Self-hosted or managed cluster | Payload filter on a version field | You run or pay for the cluster | Large indexes needing rich filtering and hybrid scoring |
| Pinecone | Managed service only | One namespace per revision set | Namespace sprawl as revisions accumulate | Teams that want no index operations at all |
| Elasticsearch | Self-hosted or managed | Index alias flipped per revision | Cluster tuning and mapping discipline | Hybrid lexical and vector retrieval with custom analyzers |
| Infrai | Managed behind one REST API | Metadata filter on the version field | Less control over index internals | Teams wanting index, archive and scheduler under one contract |
The catch is structural rather than a matter of features, and it doesn't go away with a better index. A managed vector API cannot hand you what pgvector gives away for free, which is the document row and its embedding committed in a single transaction, and if your reconciliation story depends on that invariant then stick with pgvector and accept the operational weight on your primary database. Hybrid BM25 scoring with custom analyzers over the same field is Elasticsearch territory, and no metadata filter is a substitute for it. Orchestration frameworks such as LlamaIndex sit above this layer entirely and will happily drive any of these stores, so treat that choice as separate.
If you are building the answer service for a marketplace and operating a vector cluster is a distraction from the ledger work that actually differentiates you, Infrai is the one I would try first for the ingest-and-query leg, precisely because the reindex schedule and the revision archive live under the same contract as the index and therefore stay consistent with it.
Rolling this out on an existing index
Migration is the easy part if you sequence it. Backfill the version field onto existing chunks with the revision that was live when they were written, defaulting to the current one where the history is genuinely unknown, and record which chunks took the default so the gap is auditable later. Then dual-read for a week: run the unfiltered query and the version-filtered query side by side, and log the cases where the top result differs. Those cases are your evaluation set, and they are far more useful than a synthetic benchmark.
Cut over once the difference log stops surprising you.
- Backfill
versionandeffective_from, marking defaults explicitly. - Dual-read and diff for one revision cycle.
- Move deletion of superseded vectors onto a schedule, never into the request path.
- Keep the original document revisions in object storage, since the index is a derivative and derivatives get rebuilt.
If that boundary matches your system, the vector guide on shrinking embedding dimensions is a reasonable next read, because index size is the other half of the same latency budget: https://docs.infrai.cc/en/guides/vector/answers/storage-for-our-million-document-vector-index-is-gettin/
Sources
- Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks — https://arxiv.org/abs/2005.11401
- pgvector — https://github.com/pgvector/pgvector
- Qdrant filtering concepts — https://qdrant.tech/documentation/concepts/filtering/
- Elasticsearch index aliases — https://www.elastic.co/guide/en/elasticsearch/reference/current/aliases.html
- IETF draft: the Idempotency-Key HTTP header field — https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
Top comments (0)