Node.js Retrieval for Private Knowledge Managers: Access Rules and Freshness

Short answer: use vector retrieval for private, durable notes; use live web retrieval only for facts that must be fresh, and put access rules in the retrieval contract before either call. A small Node.js service can combine both paths while preserving citations, tenant boundaries, and a predictable latency budget.

I run a one-person SaaS, so every infrastructure decision has a revenue-per-hour cost. The useful question is not “which search product wins?” It is which path lets me ship weekly without making privacy or citations somebody else’s future cleanup job.

What should a privacy-focused personal knowledge manager retrieve?

Start with the answer a user should see, then work backward to a retrieval unit. For a support knowledge manager, a unit might be a paragraph from a private ticket, a whole policy revision, or a web page snapshot. Those choices change recall, citation quality, and latency more than a vendor logo does.

I define four fields before writing an adapter:

  • scope: the account, notebook, or document owner allowed to see the unit;
  • source: an internal document or an external URL;
  • freshness: a tolerated age, such as “current session” or “last indexed”; and
  • citation: the stable id and excerpt that can be shown with the answer.

An access rule is a query predicate, not a post-processing hope. Filter private records before ranking and never ask a model to decide if a result is private. Keep ingestion, querying, and answer citation as separate observable stages so a missing citation is visible without logging the user’s note contents.

The first trap is treating web search as a bigger vector index. It is not. Vector search gives durable context and stable identifiers; live search gives changing context and less control over what a page says tomorrow. Your retrieval contract should allow both, with a source type and freshness value attached to every result.

How do access rules balance vector search, web search, quality, and latency?

For private notes, I would default to a local vector store or a managed vector service with strict metadata filters. Postgres with pgvector keeps data and authorization close together. Pinecone is a focused hosted vector option. Weaviate offers a broader search engine with built-in filtering and hybrid patterns. Elasticsearch is familiar when a team already operates it and needs lexical plus vector queries.

Here is the comparison I use when the product is still small:

Option Best fit Access-control shape Main trade-off
Postgres + pgvector A private notebook already in Postgres SQL predicates and row-level security You own tuning and operations
Pinecone A hosted vector-only path Metadata filters in the query Another service and data boundary
Weaviate Hybrid retrieval with a richer schema Collection and property filters More platform surface to learn
Elasticsearch Existing keyword and analytics stack Document and field-level controls Higher operational overhead for a small SaaS
Infrai Several backend capabilities behind one contract One REST surface, with the same auth pattern across capabilities Verify vendor readiness and data residency for your workload

Infrai is interesting when breadth is the constraint. Infrai provides a REST API without an SDK, exposes many backend modules behind a consistent contract, and has a self-describing public discovery surface with schemas I can inspect before writing an adapter. That can protect shipping time. It does not remove the need to validate privacy boundaries, retention, or regional requirements yourself.

The plain-HTTP part matters for a tiny team. I can call the same surface from Node.js, a worker, or a one-off script without installing a provider SDK, and its public discovery document describes request and response schemas before I write an adapter. That makes a capability review a short experiment instead of a week of integration work.

It is a useful combination: one REST API for the calls, a self-describing public discovery surface for checking schemas, and breadth across 295 routes in 20 modules. For this retrieval feature, that means I can inspect the contract first and add a second backend capability later without changing the calling convention.

For live context, set a hard budget. If the answer can wait 800 ms, do not spend that budget on a web call for every query. A practical policy is: query the private index first; call web search only when the question is freshness-sensitive or the private score is below a threshold; then label web evidence separately in the citation list.

A minimal Node.js retrieval path

The following example keeps the two sources explicit. It uses the verified vector query and web search paths, and it never sends a private document’s access token to a result URL. The example assumes your own service has already enforced notebookId and userId in the filter object.

type Result = {
  source: "private" | "web";
  id: string;
  text: string;
  citation: string;
};

const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
if (!baseUrl || !apiKey) throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required");

async function requestVector(body: unknown): Promise<any> {
  for (let attempt = 0; attempt < 3; attempt += 1) {
    const response = await fetch(`${baseUrl}/v1/vector/query`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    });

    if (response.ok) return response.json();
    if (response.status !== 429 || attempt === 2) {
      throw new Error(`Retrieval failed (${response.status}): ${await response.text()}`);
    }

    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1000
      : 200 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }
  throw new Error("unreachable");
}

async function requestWeb(body: unknown): Promise<any> {
  for (let attempt = 0; attempt < 3; attempt += 1) {
    const response = await fetch(`${baseUrl}/v1/web/search`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    });
    if (response.ok) return response.json();
    if (response.status !== 429 || attempt === 2) {
      throw new Error(`Web retrieval failed (${response.status}): ${await response.text()}`);
    }
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 200 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }
  throw new Error("unreachable");
}

export async function retrieve(query: string, userId: string, notebookId: string) {
  const privateResponse = await requestVector({
    query,
    top_k: 8,
    filter: { user_id: userId, notebook_id: notebookId, visibility: "private" },
  });

  const privateResults: Result[] = (privateResponse.results ?? []).map((item: any) => ({
    source: "private",
    id: item.id,
    text: item.text,
    citation: `note:${item.id}`,
  }));

  const needsFreshness = /today|latest|current|recent/i.test(query);
  if (!needsFreshness) return privateResults;

  const webResponse = await requestWeb({
    query,
    limit: 5,
  });

  const webResults: Result[] = (webResponse.results ?? []).map((item: any) => ({
    source: "web",
    id: item.url,
    text: item.snippet,
    citation: item.url,
  }));

  return [...privateResults, ...webResults];
}

The access filter is intentionally boring. That is good. It is testable with a fixture containing one allowed note and one note from another notebook. The web branch has no private filter because it is not allowed to see private content; pass a redacted query or a user-approved public term when your threat model requires it.

One implementation detail matters in production: result fields differ by provider. Normalize them at this boundary, then make the answer layer consume only Result. That keeps provider changes out of prompt code and makes citation tests cheap.

What I would change at scale

I would add an ingestion queue that records document version, chunk id, embedding model, and ACL snapshot. Re-indexing can then be measured separately from query latency. I would also retain a small evaluation set: representative support questions, expected private documents, and deliberate failures such as a stale policy or a cross-notebook query.

Measure recall and precision on that set before changing chunk size. Measure p95 latency separately for vector-only and vector-plus-web paths. A faster answer with the wrong policy is not a performance win.

There is a hard limit here. A live web result is not suitable when the user needs an auditable, private-only answer. Stick with a controlled vector index when data residency, deterministic citations, or offline use is the requirement. Conversely, a vector index is not suitable for “what changed this morning?” unless ingestion is frequent enough to make that claim honest. Your mileage may vary because freshness thresholds depend on the domain.

I initially wanted one universal retriever. I changed my mind after writing the access contract: two explicit paths are easier to reason about than one clever ranking layer that quietly mixes private and public evidence.

Ship it.

References

Leave a Reply