고객 및 카테고리별 검색 범위 지정

JavaScriptBeginner
지금 연습하기

소개

V03 에서는 질문을 임베딩으로 변환하고 가까운 도움말 문서를 검색했습니다. 하지만 실제 지원 시스템은 보통 여러 고객을 대상으로 합니다. 의미적 유사성만으로는 요청자가 어느 고객의 문서를 볼 수 있는지 결정해서는 안 됩니다.

이 실습에서는 두 가지 검색 경계를 추가합니다. Vectorize 네임스페이스는 하나의 인덱스 안에서 데이터를 분할하는 영역입니다. 특정 네임스페이스를 검색하면 유사도 순위를 계산하기 전에 다른 모든 네임스페이스의 벡터가 제외됩니다. 그런 다음 메타데이터 필터category 같은 필드로 해당 고객의 영역을 다시 좁힙니다. 네임스페이스는 올바른 서류 캐비닛을 고르는 것이고, 카테고리 필터는 그 안에서 특정 서랍을 고르는 것이라고 생각하면 됩니다.

어느 메커니즘도 사용자를 인증하지는 않습니다. 애플리케이션은 먼저 로그인, 토큰 또는 기타 신원 정보를 검증하고 서버에서 네임스페이스를 결정해야 합니다. 이 실습을 안전하고 반복 가능하게 유지하기 위해, 이 Worker 는 이미 검증된 세션을 대신하는 공개 합성 세션 레이블 두 개를 사용합니다. 이 레이블은 학습용 고정 데이터이며 실제 자격 증명이나 완전한 인증 시스템이 아닙니다. 요청이 자신의 고객이나 네임스페이스를 선택하도록 허용하지 않습니다.

Workers AI 와 Vectorize 바인딩을 사용하는 일회성 Worker 하나를 배포합니다. 네 개의 합성 문서에는 의도적으로 두 고객 네임스페이스에 동일한 비밀번호 텍스트가 포함되어 있습니다. 실시간 임베딩으로 검색을 실제처럼 수행하고, 정확한 네임스페이스·카테고리·ID 검사를 통해 모델의 정확한 점수를 평가하지 않고도 격리를 증명합니다. 또한 권한이 있는 빈 결과를 테스트하고, 클라우드 서비스를 호출하기 전에 범위 재정의 시도를 거부합니다.

이 과정으로 바로 이동했다면 먼저 LabEx 를 Cloudflare 계정에 연결을 완료합니다. V01~V03 도 선수 과정입니다. 이 과정에서는 호환되는 인덱스, 비동기 mutation, 의미 검색을 소개합니다.

작은 인덱스와 제한된 BGE Small 요청은 문서화된 Workers Free 할당량에 맞으므로 Workers Paid 는 필요하지 않습니다. 로컬 모델 호출과 배포된 모델 호출 모두 계정의 공유 Workers AI 일일 할당량을 사용합니다. 해당 할당량을 사용할 수 없으면 반복해서 재시도하지 말고 중지합니다.

설정 과정에서 /home/labex/project/scoped-vector-search에 Node.js 22.22.0 과 프로젝트 로컬 Wrangler 4.132.0 을 설치합니다. 결정적 테스트와 독립적인 읽기 전용 검사를 제공하지만, Wrangler 인증, 인덱스 생성, Worker 배포, 추론 실행 또는 클라우드 데이터 시딩은 자동으로 수행하지 않습니다.

범위가 지정된 검색 리소스 인증 및 이름 지정

이 단계에서는 새 VM 을 인증하고, 일반적인 Wrangler 구성으로 Worker 하나와 연결된 Vectorize 인덱스 하나를 정의합니다.

준비된 프로젝트로 이동하고 고정된 CLI 버전을 확인합니다.

cd /home/labex/project/scoped-vector-search
npx wrangler --version

4.132.0이 출력되어야 합니다. 디바이스 인증을 사용하면 Cloudflare 비밀번호를 VM 에 전달하지 않고 임시 OAuth 권한을 VM 에 부여할 수 있습니다. 요청하는 권한 범위는 계정 ID 확인, 일회성 인덱스, Worker 배포, 임베딩에 사용할 Workers AI 바인딩을 포함합니다.

npx wrangler login --device --browser=false --scopes account:read user:read workers:write workers_scripts:write workers_kv:write ai:write
npx wrangler whoami --json

브라우저에서 표시된 링크를 열고 현재 코드를 입력한 다음 학습에 사용할 계정을 승인합니다. 터미널로 돌아와 loggedIn: true, authType: OAuth Token, 계정 이름을 확인한 후 계정 ID 를 복사합니다.

임의의 접미사를 하나 생성한 다음 Worker 이름을 기준으로 인덱스 이름을 만듭니다. 이 소유권 지정 방식은 나중에 정확하게 정리할 수 있도록 합니다.

RUN="labex-c08-v04-$(openssl rand -hex 6)"
INDEX="$RUN-docs"
printf 'Worker: %s\nIndex:  %s\n' "$RUN" "$INDEX"

YOUR_ACCOUNT_IDwhoami 명령으로 확인한 ID 로 바꿉니다. 바인딩은 Worker 코드에서 Cloudflare 서비스를 가리키는 로컬 이름입니다. AI는 임베딩을 생성하고, DOCUMENTSindex_name에 지정한 정확한 인덱스를 조회합니다.

cat > wrangler.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "YOUR_ACCOUNT_ID",
  "main": "src/index.js",
  "compatibility_date": "2026-09-16",
  "compatibility_flags": ["nodejs_compat"],
  "workers_dev": true,
  "preview_urls": false,
  "observability": { "enabled": true },
  "ai": { "binding": "AI", "remote": true },
  "vectorize": [
    { "binding": "DOCUMENTS", "index_name": "$INDEX", "remote": true }
  ]
}
JSON

이 파일은 사용할 리소스의 이름만 지정하며 아직 아무것도 생성하지 않습니다. 공유 학습 계정에서는 쓰기 작업 전에 신원과 소유권을 명확히 지정하는 것이 특히 중요합니다.

서버에서 범위를 지정하는 검색 Worker 구축

이 단계에서는 배포하기 전에 검색 경계를 구현합니다.

x-lab-session 헤더는 이전 인증 계층의 결과를 모의하기 위해 공개 레이블 두 개만 사용합니다. resolveSession은 검증된 컨텍스트를 서버에서 네임스페이스로 매핑합니다. 요청 본문은 검색어와 허용된 카테고리를 지정할 수 있지만 고객이나 네임스페이스를 지정할 수는 없습니다. 실제 애플리케이션에서는 이 레이블을 제대로 검증된 세션이나 identity provider 로 교체합니다. 네임스페이스는 데이터 구성 방식이지 인증 수단이 아닙니다.

네 개 문서에는 blue 고객과 green 고객에 대해 동일한 비밀번호 텍스트가 포함되어 있습니다. 따라서 보안 결과를 쉽게 확인할 수 있습니다. 유사성만으로는 복사본을 구별할 수 없으므로, 서버가 제어하는 범위만 문서를 서로 분리할 수 있습니다.

cat > src/index.js <<'JS'
const MODEL = "@cf/baai/bge-small-en-v1.5";
const POOLING = "cls";
const DIMENSIONS = 384;
const ALLOWED_CATEGORIES = new Set(["account", "billing", "files"]);
const SESSION_CONTEXTS = Object.freeze({
  "blue-session": Object.freeze({ customer: "blue", namespace: "customer-blue" }),
  "green-session": Object.freeze({ customer: "green", namespace: "customer-green" })
});

const DOCUMENTS = [
  {
    id: "blue-password",
    namespace: "customer-blue",
    category: "account",
    title: "Reset a password",
    text: "Reset an expired or forgotten password to regain access to your account."
  },
  {
    id: "blue-invoice",
    namespace: "customer-blue",
    category: "billing",
    title: "Download an invoice",
    text: "Download an invoice or receipt for a completed payment."
  },
  {
    id: "green-password",
    namespace: "customer-green",
    category: "account",
    title: "Reset a password",
    text: "Reset an expired or forgotten password to regain access to your account."
  },
  {
    id: "green-upload",
    namespace: "customer-green",
    category: "files",
    title: "Upload a PDF",
    text: "Upload a PDF document and troubleshoot file size or format errors."
  }
];

function json(value, status = 200) {
  return Response.json(value, { status, headers: { "cache-control": "no-store" } });
}

export function resolveSession(label) {
  const context = SESSION_CONTEXTS[label];
  if (!context) throw new Error("session_invalid");
  return context;
}

export function parseSearchInput(value) {
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_json");
  for (const key of ["customer", "customerId", "namespace"]) {
    if (Object.prototype.hasOwnProperty.call(value, key)) throw new Error("scope_override_not_allowed");
  }
  const query = typeof value.query === "string" ? value.query.trim() : "";
  const category = typeof value.category === "string" ? value.category.trim() : "";
  if (!query || query.length > 200) throw new Error("query_required");
  if (!ALLOWED_CATEGORIES.has(category)) throw new Error("category_invalid");
  return { query, category };
}

export function validateEmbeddingBatch(result, expectedCount) {
  const vectors = result?.data;
  if (!Array.isArray(vectors) || vectors.length !== expectedCount || result?.shape?.[1] !== DIMENSIONS) {
    throw new Error("incompatible embedding batch");
  }
  for (const vector of vectors) {
    if (!Array.isArray(vector) || vector.length !== DIMENSIONS || !vector.every(Number.isFinite)) {
      throw new Error("invalid embedding vector");
    }
  }
  return vectors;
}

async function embed(env, texts) {
  const result = await env.AI.run(MODEL, { text: texts, pooling: POOLING });
  return validateEmbeddingBatch(result, texts.length);
}

async function seed(env) {
  const vectors = await embed(env, DOCUMENTS.map((document) => document.text));
  const records = DOCUMENTS.map((document, index) => ({
    id: document.id,
    namespace: document.namespace,
    values: vectors[index],
    metadata: {
      category: document.category,
      title: document.title,
      model: MODEL,
      pooling: POOLING
    }
  }));
  const mutation = await env.DOCUMENTS.upsert(records);
  console.log(JSON.stringify({ event: "scoped_documents_seeded", count: records.length, mutationId: mutation.mutationId }));
  return json({ mutationId: mutation.mutationId, count: records.length, model: MODEL, dimensions: DIMENSIONS, pooling: POOLING }, 202);
}

async function search(request, env) {
  let context;
  try {
    context = resolveSession(request.headers.get("x-lab-session") ?? "");
  } catch (error) {
    return json({ error: "session_invalid" }, 401);
  }

  let input;
  try {
    input = parseSearchInput(await request.json());
  } catch (error) {
    return json({ error: error instanceof Error ? error.message : "invalid_json" }, 400);
  }

  const [queryVector] = await embed(env, [input.query]);
  const result = await env.DOCUMENTS.query(queryVector, {
    topK: 3,
    namespace: context.namespace,
    filter: { category: input.category },
    returnMetadata: "all"
  });
  const matches = result.matches.map((match) => ({
    id: match.id,
    score: match.score,
    namespace: match.namespace,
    title: match.metadata?.title,
    category: match.metadata?.category
  }));
  console.log(JSON.stringify({
    event: "scoped_search",
    customer: context.customer,
    namespace: context.namespace,
    category: input.category,
    returnedCount: matches.length
  }));
  return json({
    customer: context.customer,
    namespace: context.namespace,
    category: input.category,
    candidateCount: result.matches.length,
    matches
  });
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (request.method === "POST" && url.pathname === "/seed") return seed(env);
    if (request.method === "POST" && url.pathname === "/search") return search(request, env);
    return json({ error: "not_found" }, 404);
  }
};
JS

결정적 테스트를 실행합니다. 메모리 내 바인딩을 사용하는 이 테스트는 클라우드 할당량을 사용하지 않고 Worker 가 서버 측 컨텍스트에서 두 검색 경계를 모두 구성하는지 확인합니다.

node --test test/worker.test.mjs

6 개의 테스트가 모두 통과해야 합니다. 실제 구성에서 바인딩 타입을 생성한 다음 배포하지 않고 번들합니다.

npx wrangler types
npx wrangler deploy --dry-run --outdir /tmp/v04-dry-run

생성된 파일에 AI: AiDOCUMENTS: VectorizeIndex가 포함되어야 합니다. 드라이 런은 소스와 구성이 함께 번들되는지만 확인하며, 어느 클라우드 리소스도 생성하거나 테스트하지 않습니다.

필터링 가능한 인덱스 생성 및 배포

이 단계에서는 호환되는 인덱스를 생성하고, category 필드를 필터링에 사용할 수 있도록 준비한 후, 준비 작업이 처리된 뒤에만 Worker 를 배포합니다.

벡터에 메타데이터를 저장하는 것만으로는 해당 메타데이터를 검색할 수 없습니다. 메타데이터 인덱스는 Vectorize 에 필터 우선 검색을 위해 어떤 필드를 구성할지 알려줍니다. 문서 벡터를 삽입하기 전에 메타데이터 인덱스가 존재해야 합니다. 그렇지 않으면 먼저 저장한 레코드는 해당 메타데이터 필터에 참여하지 않습니다.

BGE Small 과 일치하는 384 차원 cosine 인덱스를 생성합니다. --update-config=false를 지정하면 이미 검토한 명시적 바인딩을 Wrangler 가 덮어쓰지 않습니다.

npx wrangler vectorize create "$INDEX" --dimensions=384 --metric=cosine --update-config=false

이제 문자열 필드 category의 준비 작업을 큐에 넣고 mutation ID 를 보존합니다.

set -o pipefail
npx wrangler vectorize create-metadata-index "$INDEX" \
  --propertyName=category \
  --type=string 2>&1 | tee .labex/category-index-output.txt
META_MUTATION=$(grep -Eo '[0-9a-fA-F]{8}-[0-9a-fA-F-]{27}' .labex/category-index-output.txt | tail -n 1)
if [ -z "$META_MUTATION" ]; then
  printf '%s\n' 'No metadata mutation ID was returned; fix the command before continuing.' >&2
else
  printf '%s\n' "$META_MUTATION" | tee .labex/category-mutation.txt
fi

승인된 mutation 은 큐에 추가된 작업일 뿐, 작업이 완료되었다는 뜻은 아닙니다. 시딩 후에도 재사용할 수 있도록 제한된 읽기 전용 대기 스크립트를 하나 작성합니다. 이 스크립트는 같은 mutation 과 벡터 수를 연속 세 번 읽어야 성공으로 처리합니다. 따라서 잠시 오래된 읽기 결과가 실습의 최종 증거가 되는 것을 방지합니다.

cat > scripts/wait-for-vectorize.mjs <<'JS'
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";

const [indexName, mutationFile, expectedText] = process.argv.slice(2);
const mutationId = readFileSync(mutationFile, "utf8").trim();
const expectedCount = Number(expectedText);
if (!/^[0-9a-f-]{36}$/i.test(mutationId)) throw new Error("mutation file has no UUID");
if (!Number.isInteger(expectedCount) || expectedCount < 0) throw new Error("expected count is invalid");
const wrangler = "./node_modules/wrangler/bin/wrangler.js";
let consecutiveMatches = 0;

for (let attempt = 1; attempt <= 120; attempt += 1) {
  const output = execFileSync(process.execPath, [wrangler, "vectorize", "info", indexName, "--json"], { encoding: "utf8" });
  const info = JSON.parse(output);
  if (String(info.processedUpToMutation) === mutationId && info.vectorCount === expectedCount) consecutiveMatches += 1;
  else consecutiveMatches = 0;
  if (consecutiveMatches === 3) {
    console.log("mutation " + mutationId + " is consistently readable with " + expectedCount + " vectors");
    console.log(JSON.stringify(info, null, 2));
    process.exit(0);
  }
  await new Promise((resolve) => setTimeout(resolve, 2000));
}
throw new Error("mutation " + mutationId + " was not stable within four minutes");
JS
node scripts/wait-for-vectorize.mjs "$INDEX" .labex/category-mutation.txt 0

별도의 목록 화면이 따라잡기 전에 mutation 처리가 완료될 수 있습니다. 읽기 전용 루프를 제한된 횟수로 실행해 목록에 category 행이 표시될 때까지 기다립니다. 오래된 목록 응답 하나를 실패로 처리하지 않도록 합니다.

for attempt in {1..15}; do
  METADATA_INDEXES=$(npx wrangler vectorize list-metadata-index "$INDEX" 2>&1)
  if grep -Eq 'category.*String' <<<"$METADATA_INDEXES"; then
    break
  fi
  sleep 2
done
printf '%s\n' "$METADATA_INDEXES"
grep -Eq 'category.*String' <<<"$METADATA_INDEXES" || {
  printf '%s\n' 'The category metadata index is processed but not yet visible; rerun this read-only check.' >&2
  exit 1
}

타입이 Stringcategory가 표시되어야 합니다. 이제 DOCUMENTS 바인딩이 준비된 이 인덱스를 가리키는 Worker 를 배포합니다.

set -o pipefail
npx wrangler deploy 2>&1 | tee .labex/deploy-output.txt
DEPLOY_URL=$(sed -nE 's#.*(https://[^[:space:]]+\.workers\.dev).*#\1#p' .labex/deploy-output.txt | tail -n 1)
if [ -z "$DEPLOY_URL" ]; then
  printf '%s\n' 'No workers.dev URL was returned; fix deployment before continuing.' >&2
else
  printf '%s\n' "$DEPLOY_URL" | tee .labex/deploy-url.txt
fi

인덱스는 아직 비어 있습니다. 배포는 바인딩을 연결할 뿐이며 문서 임베딩을 자동으로 생성하지 않습니다.

두 고객 네임스페이스에 데이터 시딩

이 단계에서는 실시간 임베딩을 생성하고 각 레코드를 정확히 하나의 고객 네임스페이스에 카테고리 메타데이터와 함께 저장합니다.

네임스페이스는 벡터 레코드 자체에 속합니다. 두 비밀번호 레코드는 의도적으로 동일한 텍스트를 포함하지만 서로 다른 파티션에 저장됩니다. category는 별도의 메타데이터이므로 하나의 레코드는 동시에 blue 네임스페이스와 account 카테고리에 속할 수 있습니다.

고정된 시드 엔드포인트를 한 번 호출합니다. 문서 모음은 서버에서 관리하므로 요청 본문은 비어 있습니다.

DEPLOY_URL=$(cat .labex/deploy-url.txt)
curl --fail-with-body --silent --show-error \
  -X POST "$DEPLOY_URL/seed" \
  -H 'content-type: application/json' \
  --data '{}' | tee .labex/seed-response.json
node -e '
  const value = JSON.parse(require("fs").readFileSync(".labex/seed-response.json", "utf8"));
  if (!/^[0-9a-f-]{36}$/i.test(value.mutationId)) throw new Error("seed mutation is missing");
  require("fs").writeFileSync(".labex/seed-mutation.txt", value.mutationId + "\n");
  console.log("accepted " + value.count + " scoped vectors in mutation " + value.mutationId);
'

count: 4, 384 차원, cls pooling, mutation UUID 가 표시되어야 합니다. 서비스에 필요한 시간을 추측하지 말고 정확한 mutation 과 벡터 수를 확인할 때까지 기다립니다.

node scripts/wait-for-vectorize.mjs "$INDEX" .labex/seed-mutation.txt 4
for attempt in {1..15}; do
  VECTOR_LIST=$(npx wrangler vectorize list-vectors "$INDEX" --count=10 2>&1)
  if grep -q 'blue-password' <<<"$VECTOR_LIST" &&
     grep -q 'blue-invoice' <<<"$VECTOR_LIST" &&
     grep -q 'green-password' <<<"$VECTOR_LIST" &&
     grep -q 'green-upload' <<<"$VECTOR_LIST"; then
    break
  fi
  sleep 2
done
printf '%s\n' "$VECTOR_LIST"
for id in blue-password blue-invoice green-password green-upload; do
  grep -q "$id" <<<"$VECTOR_LIST" || {
    printf 'The processed vector %s is not visible in the list yet; rerun this read-only check.\n' "$id" >&2
    exit 1
  }
done

목록에는 blue-password, blue-invoice, green-password, green-upload이 있어야 합니다. ID 는 검색 결과를 원본 문서와 연결합니다. 네임스페이스와 카테고리는 유사한 레코드가 해당 쿼리의 후보가 될 수 있는지를 결정합니다.

고객 및 카테고리 격리 증명

이 단계에서는 두 고객으로 동일한 의미 질문을 실행한 다음, 권한이 있지만 빈 결과인 경우와 안전하지 않은 범위 재정의를 테스트합니다.

blue 합성 세션과 account 카테고리로 시작합니다.

curl --fail-with-body --silent --show-error \
  -X POST "$DEPLOY_URL/search" \
  -H 'content-type: application/json' \
  -H 'x-lab-session: blue-session' \
  --data '{"query":"My password expired","category":"account"}' \
  | tee .labex/blue-account.json

응답에는 customer: blue, namespace: customer-blue이 표시되고 blue-password만 포함되어야 합니다. 이제 동일한 질문을 green 세션으로 보냅니다.

curl --fail-with-body --silent --show-error \
  -X POST "$DEPLOY_URL/search" \
  -H 'content-type: application/json' \
  -H 'x-lab-session: green-session' \
  --data '{"query":"My password expired","category":"account"}' \
  | tee .labex/green-account.json

이번에는 green-password만 후보가 됩니다. 두 문서의 텍스트는 동일하므로 이 차이는 임베딩 모델이나 우연히 나온 점수가 아니라, 검증된 세션이 선택한 네임스페이스에서 발생합니다.

이제 blue 세션으로 files 카테고리를 검색합니다. 매우 관련성이 높은 green 업로드 문서는 존재하지만 blue 네임스페이스에는 파일 문서가 없습니다.

curl --fail-with-body --silent --show-error \
  -X POST "$DEPLOY_URL/search" \
  -H 'content-type: application/json' \
  -H 'x-lab-session: blue-session' \
  --data '{"query":"Upload a PDF","category":"files"}' \
  | tee .labex/blue-files-empty.json

candidateCount: 0matches: []가 표시되어야 합니다. 빈 결과가 올바른 권한 부여 결과입니다. 다른 네임스페이스에서 관련 레코드를 가져오면 데이터 유출이 됩니다.

마지막으로 요청 본문에서 네임스페이스를 재정의해 봅니다.

curl --silent --show-error \
  -o .labex/override-response.json \
  -w 'HTTP %{http_code}\n' \
  -X POST "$DEPLOY_URL/search" \
  -H 'content-type: application/json' \
  -H 'x-lab-session: blue-session' \
  --data '{"query":"Upload a PDF","category":"files","namespace":"customer-green"}'
cat .labex/override-response.json

HTTP 400 과 scope_override_not_allowed가 표시되어야 합니다. Worker 는 임베딩 또는 Vectorize 쿼리를 실행하기 전에 해당 필드를 거부합니다. 클라이언트는 허용된 카테고리를 요청할 수 있지만, 신원 정보를 고객 네임스페이스로 매핑하는 작업은 신뢰할 수 있는 서버 로직만 수행해야 합니다.

Workers & Pages → 해당 labex-c08-v04-... Worker → Bindings를 엽니다. AI가 Workers AI 를 가리키고 DOCUMENTS가 일회성 Vectorize 인덱스를 정확히 가리키는지 확인합니다. 이 연결을 통해 코드의 env.AIenv.DOCUMENTS가 관리형 서비스에 접근하는 방식을 시각적으로 확인할 수 있습니다. 독립적인 검사에서는 정확한 바인딩 ID 도 확인합니다.

Worker Bindings 화면에서 AI 는 Workers AI 에 연결되고 DOCUMENTS 는 일회성 Vectorize 인덱스에 연결됩니다

그런 다음 AI → Vectorize → 일치하는 -docs 인덱스를 엽니다. 현재 벡터 수는 결국 4 가 되어야 하며 쿼리 메트릭에는 범위가 지정된 검색이 반영되기 시작해야 합니다. 대시보드 카운터는 늦게 반영될 수 있으므로, 정확한 ID·네임스페이스·카테고리 확인에는 인증된 레코드 읽기와 HTTP 응답을 기준으로 사용합니다.

Vectorize 요약 화면에 현재 벡터 4 개와 최근 범위 지정 쿼리가 표시됩니다

Workers Logs 를 사용할 수 있다면 Worker 의 Observability → Logs 화면을 열고 scoped_search 항목을 확인합니다. 이 로그에는 합성 고객 레이블, 네임스페이스, 카테고리, 반환된 개수만 기록되고 질문 텍스트나 세션 레이블은 기록되지 않습니다. 구조화되고 개인정보를 제한하는 로그를 사용하면 민감한 요청 내용을 복사하지 않고 어떤 서버 선택 범위가 실행되었는지 진단할 수 있습니다.

scoped_search 로그에 합성 고객, 서버가 선택한 네임스페이스, 카테고리, 반환된 개수가 기록됩니다

이 스크린샷은 일회성 테스트 실행에서 가져온 예시입니다. 실제 실행에서는 임의의 리소스 이름, 타임스탬프, 지연 시간, 쿼리 수가 달라집니다. 예시 값을 그대로 복사하지 말고 바인딩 이름, 현재 벡터 수, 필드 간 관계를 비교합니다.

범위 지정 검색 리소스 삭제

이 단계에서는 일회성 Worker 와 인덱스를 삭제한 다음, Wrangler 인증이 유지되는 동안 삭제되었음을 확인합니다.

이전 터미널 세션의 변수에 의존하지 않도록 wrangler.jsonc에서 정확한 이름을 다시 읽습니다.

RUN=$(node -p 'JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).name')
INDEX=$(node -p 'JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).vectorize.find((item) => item.binding === "DOCUMENTS").index_name')
printf 'Worker: %s\nIndex: %s\n' "$RUN" "$INDEX"

두 값이 모두 고유한 labex-c08-v04-... 접두사로 시작하는지 확인합니다. 배포된 코드에 바인딩이 남아 있지 않도록 Worker 를 먼저 삭제한 다음 연결된 인덱스만 삭제합니다.

npx wrangler delete --name "$RUN" --force
npx wrangler vectorize delete "$INDEX" --force

인증된 인덱스 목록을 저장하고 정확한 이름이 없는지 확인합니다.

npx wrangler vectorize list --json > .labex/indexes-after-cleanup.json
node -e '
  const rows = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
  if (rows.some((row) => row.name === process.argv[2])) throw new Error("lab index still exists");
  console.log("lab index is absent");
' .labex/indexes-after-cleanup.json "$INDEX"

로그아웃하기 전에 이 단계를 완료합니다. 네트워크 오류나 인증 오류만으로는 삭제 여부를 판단할 수 없습니다. 평가에서는 계정 읽기가 성공하고 정확한 이름의 리소스가 없어야 합니다.

학습 VM 에서 로그아웃

이 단계에서는 이 VM 의 임시 Wrangler 인증을 삭제합니다. 클라우드 리소스는 이미 삭제되었고 인증된 정리 확인도 완료되었습니다.

npx wrangler logout
npx wrangler whoami --json

loggedIn: false가 표시되어야 합니다. Cloudflare Dashboard 의 브라우저 세션은 별개이므로 학습 계정에서 계속 사용할 수 있습니다.

요약

의미 검색에 두 가지 독립적인 후보 적합성 검사를 추가했습니다. 검증된 합성 세션이 서버에서 하나의 고객 네임스페이스를 선택했고, 인덱싱된 category 필드가 Vectorize 가 결과 순위를 계산하기 전에 해당 영역을 다시 좁혔습니다. 동일한 비밀번호 문서를 통해 유사성만으로는 고객 격리를 적용할 수 없음을 확인했고, blue 세션의 files 검색을 통해 다른 고객의 관련 레코드를 가져오는 것보다 권한이 있는 빈 결과가 더 안전하다는 점을 확인했습니다.

또한 추론 전에 클라이언트가 제공한 고객 및 네임스페이스 재정의를 거부하고, Dashboard 에서 실제 바인딩·인덱스·개인정보가 제한된 로그 간의 관계를 확인했습니다. 인증된 상태에서 일회성 Worker 와 인덱스를 삭제한 뒤 로그아웃했습니다. V05 에서는 이 안전한 검색 경계를 재사용해 제한된 원본 근거를 구성한 다음 언어 모델이 답변하도록 합니다.