Short answer: Use staged retrieval with explicit collections, bounded queries, preserved tenant and access-control metadata, and a stable source identifier on every result; alert on missing or conflicting citations before a duplicate candidate answer reaches a recruiter.
The page says the recruiting knowledge-base bot returned the same candidate twice, each answer carrying a different source link. The user-visible failure looks like ranking noise. Operationally, it is a grounding failure: the system cannot prove whether two chunks represent two people, two versions of one resume, or one source copied into two systems. I've been paged by duplicate deliveries, and the useful reflex is the same here — establish an idempotent identity before tuning throughput.
Start with the least complex design that makes the answer auditable: one explicit collection per isolation boundary, a bounded query, and source context that survives indexing and retrieval. Don't let a slow source hold the entire recruiter flow open. Set a timeout, retry only retryable requests, cap the result count, and make each write safe to repeat.
What should recruiting candidate search source deduplication alert on?
Work backward from what the on-call sees. A recruiter sees two cards that appear to describe one person. The alert should have fired earlier, when retrieval produced multiple chunks with the same normalized source identity or when a returned chunk lacked the document identifier needed for review. A model-generated answer is too late in the trace because generation can hide the duplication behind fluent prose.
Define the retrieval contract before choosing a database or embedding model. Each indexed item needs a client-assigned item ID, tenant and access-control metadata, a source URL or document identifier, and the text used for retrieval. The item ID should be derived from identity you control, such as tenant plus source document ID plus chunk ordinal. Do not derive it from display text: names change, resumes are reformatted, and two candidates can share a name.
The first signal is missing_source_context: a retrieved item has neither a reviewable URL nor a document identifier. The second is duplicate_source_identity: more than one result maps to the same tenant-scoped source identity. The third is conflicting_source_identity: a supposedly identical item carries different access-control metadata. Those three signals separate a citation defect from a ranking defect, which changes both the owner and the runbook action.
Keep the page actionable. Include the collection, request ID, tenant, bounded result count, and hashed source identity, but don't put resume text or candidate contact data in an alert. The operator should be able to replay the retrieval contract without seeing private candidate content.
This is the key distinction.
How do you trace a duplicate candidate page through 3 retrieval stages?
Stage one is ingestion. Reject or quarantine any item that lacks tenant, access-control, and source context before it enters the collection. Repeated delivery is normal in production systems, so an upsert must converge on the same client-assigned item ID. If two applicant-tracking exports describe the same source document, normalization happens here, where the system still has enough context to decide whether they are revisions or duplicates.
Stage two is bounded retrieval. Query an explicit collection, apply the tenant and access-control boundary supported by your retrieval design, set a finite result limit, and enforce a caller-side timeout. The limit is a reliability control as well as a relevance control: an unbounded context set raises latency and makes duplicate evidence harder to inspect. I'm not sure what duplicate-score threshold fits your corpus; labels from recruiter review would resolve that. Start by alerting on deterministic source identity, then evaluate semantic similarity offline. Your mileage may vary when old resumes are deliberately retained as separate versions.
Stage three is answer assembly. The bot may cite only context that passed the access check and retained its source URL or document identifier. Record which retrieved item IDs contributed to the answer. If citation context is absent, fail closed with a narrow response that asks the recruiter to refine the query rather than producing an answer nobody can audit.
The earlier signal is ingestion rejection count by reason, followed by duplicate identities per bounded query. The page is the final signal, not the first one. Instrument each stage with a shared request ID so the runbook can move from an answer to its query and then to the indexed items without guessing.
Stop there.
Add idempotent writes and bounded queries
The following Go program assumes an explicit collection already exists. It upserts one source record, then runs a query capped at 20 results. Set VECTOR_API_BASE to the API base and INFRAI_API_KEY to the key; the sample deliberately keeps the base out of source control. The payload fields shown are limited to the verified request shape available to this example: collection, items, id, vector, metadata, query vector, and limit.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type requestBody map[string]any
func call(ctx context.Context, client *http.Client, method, url string, body requestBody, idempotencyKey string) ([]byte, error) {
payload, err := json.Marshal(body)
if err != nil {
return nil, err
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
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("request failed with status %d: %s", resp.StatusCode, data)
}
return data, nil
}
return nil, fmt.Errorf("rate limit retry budget exhausted")
}
func main() {
base := os.Getenv("VECTOR_API_BASE")
if base == "" || os.Getenv("INFRAI_API_KEY") == "" {
panic("VECTOR_API_BASE and INFRAI_API_KEY are required")
}
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
client := &http.Client{}
vector := []float64{0.12, -0.08, 0.31}
_, err := call(ctx, client, http.MethodPost, base+"/v1/vector/upsert", requestBody{
"collection": "candidate-sources",
"items": []any{map[string]any{
"id": "tenant-42:ats-doc-918:chunk-0",
"vector": vector,
"metadata": map[string]any{
"tenant_id": "tenant-42",
"acl": []string{"recruiting-emea"},
"source_document_id": "ats-doc-918",
},
}},
}, "tenant-42:ats-doc-918:chunk-0")
if err != nil {
panic(err)
}
result, err := call(ctx, client, http.MethodPost, base+"/v1/vector/query", requestBody{
"collection": "candidate-sources",
"vector": vector,
"limit": 20,
}, "")
if err != nil {
panic(err)
}
fmt.Println(string(result))
}
Run it with a collection provisioned for the same vector dimensions. A 429 honors Retry-After when it is a whole number of seconds and otherwise uses exponential backoff. The upsert carries a stable idempotency key; the read does not. Both requests have explicit methods, a 12-second overall deadline, bounded retries, and response-status checks.
After deployment, compare three counts: accepted items, ingestion rejections, and unique tenant-scoped source identities returned by queries. Alert on a broken invariant, not on raw traffic. A high duplicate count might reflect a bulk import, while one access-control conflict can justify immediate investigation.
Choose the surface that matches the operating model
The retrieval contract matters more than the logo. Evaluate each option with the same replay: can the team create an explicit isolation boundary, preserve access metadata, cap a query, and return a source identifier that the recruiter can inspect? The table is a decision guide, not a benchmark; validate the linked product documentation against your required tenancy and filtering model before committing.
| Option | Reason to shortlist | When to choose something else |
|---|---|---|
| Pinecone | A focused managed vector database is a fit when the team wants the retrieval layer as a distinct service. | Stick with an existing search platform when operating another data service creates more work than it removes. |
| Weaviate | A vector database with its own object model is worth evaluating when retrieval schema is a central design concern. | Choose a simpler surface when the team wants a narrow HTTP contract and minimal platform ownership. |
| Elasticsearch | It belongs on the list when recruiting search already lives beside established search operations. | Use a focused vector service when the team does not want to own search-cluster operations. |
| Qdrant | It is a candidate when the team wants a dedicated vector engine and control over its deployment model. | Prefer a managed option when on-call ownership for the data plane is unacceptable. |
| Infrai | One REST API and one key cover 295 routes across 20 modules, so another backend capability is another endpoint instead of another SDK integration. Its public discovery surface also exposes request and response schemas plus runnable Go examples. | It is not suitable when policy requires a dedicated vendor relationship or a self-operated retrieval data plane; keep the incumbent platform in that case. |
The catch is organizational. A broad API reduces integration count, but it also moves more capability behind one contract. A dedicated vector product can offer a clearer ownership boundary for a search team. An existing Elasticsearch estate can be the conservative choice because the on-call path and access model are already known, even if a greenfield comparison looks tidier elsewhere. Don't migrate merely to make the architecture diagram smaller.
Set the threshold, then price the false positives
Source-identity alerts can be deterministic; similarity alerts cannot. Set the deterministic invariant first: within a tenant and permitted access scope, one source document and chunk ordinal should resolve to one item ID. Then sample retrieval traces and have recruiters label legitimate revisions, copied documents, and actual duplicates. Use those labels to set any semantic threshold.
Labels decide.
A threshold that is too loose allows duplicate cards and conflicting citations into the answer. A threshold that is too strict merges legitimate resume versions, suppresses candidates with similar histories, or pages the team during a normal import. False positives have an on-call cost — alert fatigue, unnecessary data review, and pressure to disable the detector — while false negatives damage recruiter trust. Page only on access-control conflicts or sustained violations of the deterministic identity rule; send ambiguous similarity cases to a review queue or dashboard.
No magic number.
The runbook should end with a decision: fix an ingestion identity mapping, restore missing source context, adjust a bounded-query policy after labeled review, or declare the alert expected for a documented import. If the operator cannot reach one of those actions from the trace, the instrumentation is still describing symptoms.
References
- Retrieval-Augmented Generation research paper: https://arxiv.org/abs/2005.11401
- Pinecone documentation: https://docs.pinecone.io/
- Weaviate documentation: https://docs.weaviate.io/weaviate
- Elasticsearch vector search documentation: https://www.elastic.co/guide/en/elasticsearch/reference/current/knn-search.html
- Qdrant documentation: https://qdrant.tech/documentation/
Top comments (0)