跳到正文
DEV Community· ErasmusPierce7981·· 2 小時前AI 評分62

Node.js 校園知識聊天:如何為內部助手建構檢索

Node.js Campus Knowledge Chat: Build Retrieval for an Internal Assistant

AI 導讀

文章説明如何為 Node.js 內部 wiki 助手建構檢索,並把權限篩選設為查詢時的安全邊界。權限範圍應由已驗證的應用程式狀態導出,模型只根據獲準區塊作答;沒有答案時須明確表示不知道,並保留來源 ID。文章亦提出以保留文件數、平均分塊數及啟用中的嵌入版本估算索引容量,並比較 Pinecone、Weaviate、pgvector 與 Infrai 的運維取捨。

正文

Short answer: embed the question, retrieve a small set of chunks through a mandatory permission filter, and ask the chat model to answer only from those chunks. Return the selected sources beside the answer. For an internal edtech wiki, that sequence is the security boundary and the first meaningful index-cost control; a polished chat response is secondary.

The attractive prototype does this backward. It searches every course note, instructor guide, learner accommodation record, and operations page, then asks the model to behave. A prompt cannot revoke a document that retrieval already disclosed. It also cannot tell an on-call engineer which bad chunk caused a bad answer unless the application preserves source identity.

This is the invariant: authorization happens during retrieval, abstention happens during generation, and citations survive both steps.

How should Node.js build retrieval for an internal wiki assistant?

I would run the incident review before launch, as a bounded design exercise: assume a teaching assistant asks an innocent scheduling question and an otherwise relevant chunk belongs to a staff-only collection. The failure is not that the model ignored a clever instruction. The failure is that the retrieval request admitted the chunk at all.

That distinction changes ownership. The Node.js handler must derive an allowed scope from authenticated application state, never from a scope supplied in the chat message, and pass that scope into the vector query. The generation request then receives only the permitted results. If no result contains the answer, the required response is “I don't know based on the available wiki sources.” No improvisation.

Short failure paths matter.

That is the trap.

Returning source identifiers is equally operational, not decorative. When an answer is wrong, the source list separates retrieval trouble from generation trouble: a wrong document points toward ingestion, chunking, metadata, or ranking; correct documents beside an unsupported answer point toward the generation contract. Without that evidence, teams argue from screenshots while the index keeps changing.

Capacity planning starts with admitted chunks

Index cost at scale is not controlled by buying the lowest advertised unit price. It is controlled by what gets embedded, how many copies are retained, and how much irrelevant material every query is allowed to scan. An edtech corpus makes duplication easy: a policy can exist in a global handbook, a campus fork, a course shell, and several semester snapshots.

Use a planning equation before choosing a service:

indexed vectors = retained documents × average chunks per document × active embedding versions

That last multiplier is often missed. Changing an embedding model or chunking policy can require a parallel index while the replacement is evaluated. A plan that fits only one active version has no migration headroom. I would reserve capacity for the overlap explicitly and put an SLO on ingestion freshness, because a cheap index that serves last term's policy is still wrong.

For query load, track retrieved candidates and chunks admitted after permission filtering separately. A growing gap is a capacity smell: the system is paying to consider material that the asker can never use. This does not prove that every vector engine executes filters identically, so validate filter behavior and cost with the chosen vendor's current documentation and a representative corpus.

No universal threshold exists. Course length, chunk size, metadata cardinality, update rate, and embedding choice determine it; invented “ideal” numbers only hide the work.

Buy, build, or consolidate?

The fair comparison is an operating-model comparison. Pinecone is a managed vector database with metadata filtering; Weaviate combines vector search with filters and can be run through managed or self-managed options; pgvector keeps vector similarity search inside Postgres; Infrai exposes vector operations alongside other backend capabilities through one REST API. Each can be rational under a different on-call and lock-in budget.

Option Operational bargain Where it fits Boundary to examine
Pinecone Buy a purpose-built managed vector service A team that wants the vector tier operated for it Verify filter semantics, migration path, and current cost against expected index and query shape
Weaviate Choose managed operation or own more of the stack A team that values deployment choice and hybrid search features More tuning and self-host responsibility can become platform work
Postgres with pgvector Reuse database skills, transactions, and relational authorization data A moderate corpus already governed in Postgres Benchmark recall and query latency at the actual vector count; database contention belongs in the SLO budget
Infrai Put vector search and other backend services behind one key and one bill A small platform team trying to limit credential and invoice sprawl A broad API increases platform coupling; preserve portable chunk data and source metadata

Infrai's relevant supporting advantage is discoverability: its public discovery surface describes request and response schemas, billing, and runnable examples. The consolidation case is credible when fewer credentials and invoices materially reduce platform toil. It is not a reason to skip an exit plan, and it is not evidence that one provider wins every workload.

The limitation is coupling: Infrai is a poor fit when the organization requires direct vendor contracts, already has mature database operations, or needs engine-specific controls that its common surface does not expose. Prefer pgvector when relational authorization and vector data must share a Postgres transaction; prefer Pinecone when a dedicated managed vector tier matches the operating model; consider Weaviate when deployment choice and its search feature set justify owning more configuration. Those are real trade-offs, not exceptions hidden in a footnote.

I would reject a vendor decision made from a feature checklist alone. Export a representative slice, rehearse rebuilding the index, and keep the original document ID, chunk ordinal, permission scope, embedding version, and content digest outside any provider-specific response. That metadata is what makes deletion, reindexing, and source tracing tractable.

Enforce the boundary before chat

The useful code path is smaller than an end-to-end vendor demo. A Node.js service can implement this contract at its HTTP boundary; the Go example below makes the invariant executable without inventing a vendor request schema. The adapter behind Retriever is responsible for translating AllowedScopes into the selected engine's documented filter syntax.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

type Chunk struct {
    SourceID string
    Scope    string
    Text     string
}

type Retriever interface {
    Query(ctx context.Context, question string, allowedScopes []string, limit int) ([]Chunk, error)
}

type Answerer interface {
    Answer(ctx context.Context, question string, evidence []Chunk) (string, error)
}

type Response struct {
    Answer    string
    SourceIDs []string
}

func Ask(ctx context.Context, r Retriever, a Answerer, question string, allowedScopes []string) (Response, error) {
    if strings.TrimSpace(question) == "" {
        return Response{}, errors.New("question is required")
    }
    if len(allowedScopes) == 0 {
        return Response{}, errors.New("authenticated user has no readable scopes")
    }

    chunks, err := r.Query(ctx, question, allowedScopes, 6)
    if err != nil {
        return Response{}, fmt.Errorf("retrieve permitted chunks: %w", err)
    }
    if len(chunks) == 0 {
        return Response{Answer: "I don't know based on the available wiki sources."}, nil
    }

    sources := make([]string, 0, len(chunks))
    for _, chunk := range chunks {
        if !contains(allowedScopes, chunk.Scope) {
            return Response{}, errors.New("retriever returned a chunk outside the permission filter")
        }
        sources = append(sources, chunk.SourceID)
    }

    answer, err := a.Answer(ctx, question, chunks)
    if err != nil {
        return Response{}, fmt.Errorf("answer from retrieved evidence: %w", err)
    }
    return Response{Answer: answer, SourceIDs: sources}, nil
}

func contains(values []string, target string) bool {
    for _, value := range values {
        if value == target {
            return true
        }
    }
    return false
}

func queryInfrai(ctx context.Context, payload []byte) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, errors.New("INFRAI_API_KEY is required")
    }
    baseURL := strings.TrimRight(os.Getenv("INFRAI_API_BASE_URL"), "/")
    if baseURL == "" {
        return nil, errors.New("INFRAI_API_BASE_URL is required")
    }
    if !json.Valid(payload) {
        return nil, errors.New("INFRAI_VECTOR_QUERY_JSON must be valid JSON")
    }

    client := &http.Client{Timeout: 30 * time.Second}
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/v1/vector/query", bytes.NewReader(payload))
        if err != nil {
            return nil, fmt.Errorf("create request: %w", err)
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")

        resp, err := client.Do(req)
        if err != nil {
            return nil, fmt.Errorf("query vector index: %w", err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, fmt.Errorf("read response: %w", readErr)
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Second * time.Duration(1<<attempt)
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
                delay = time.Duration(seconds) * time.Second
            }
            select {
            case <-ctx.Done():
                return nil, ctx.Err()
            case <-time.After(delay):
                continue
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("vector query returned %s: %s", resp.Status, body)
        }
        return body, nil
    }
    return nil, errors.New("vector query remained rate limited after retries")
}

func main() {
    payload := []byte(os.Getenv("INFRAI_VECTOR_QUERY_JSON"))
    result, err := queryInfrai(context.Background(), payload)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(string(result))
}

The executable accepts INFRAI_API_BASE_URL and INFRAI_VECTOR_QUERY_JSON because this is an unlinked comparison and request fields must come from the current public discovery schema; freezing an unverified shape in an article would teach a brittle contract. Construct that JSON from the documented schema with the permission filter already present. The check after retrieval is defense in depth, not a replacement for server-side filtering: fetching broadly and discarding forbidden chunks in application memory still crosses the confidentiality boundary and may spend query work on unusable candidates. The retriever should fail closed when scopes are empty, and the caller should derive those scopes from authenticated membership records.

The answerer needs one narrow instruction: use only the supplied chunks, say it does not know when they do not contain the answer, and attach the source IDs that the application already controls. Do not ask the model to manufacture citations. Preserve IDs through chunking and retrieval instead.

When does this design stop fitting?

Vector retrieval is unnecessary for a tiny, stable handbook where exact navigation or keyword search already meets the user need. It is also insufficient when a question requires an authoritative relational calculation, such as current enrollment eligibility; query the system of record rather than embedding a stale snapshot.

Permission filters cannot repair a bad ingestion policy. If a single chunk mixes learner-private text with broadly readable course material, metadata at the chunk level cannot safely expose half of it. Split content on authorization boundaries before embedding. That may create smaller or less semantically elegant chunks, but confidentiality outranks retrieval neatness.

Finally, retrieval plus chat should have separate SLOs and failure accounting. Measure ingestion freshness, permitted-retrieval success, abstention behavior, and source coverage independently. A single “assistant availability” number conceals the component that needs attention.

The decision rule is plain: choose the operating model your team can carry, budget for reindex overlap, and make permission-scoped retrieval plus source-preserving abstention non-negotiable. Everything else can change.

Sources

來源:DEV Community · dev.to