Cloudflare Workers·D1·Vectorize에서 의미 메모리 레이어 구축의 과제

발행: (2026년 6월 6일 PM 08:23 GMT+9)
8 분 소요
원문: Dev.to

출처: Dev.to

핵심 개념은 간단합니다: 텍스트를 임베드하고, 벡터를 저장한 뒤, 나중에 쿼리합니다. 시간이 많이 걸리는 부분은 그 외 모든 것이었습니다.

저는 Cloudflare Workers, D1, Vectorize, 그리고 Workers AI를 활용해 AI 도구들 간의 컨텍스트를 유지하는 메모리 레이어를 만들었습니다. 이 모든 것이 무료 티어에서 동작합니다. 처음에 깨닫지 못했던 점들을 정리해 보았습니다.

두 개의 스토어, 철저히 분리

D1은 콘텐츠, 태그, 타임스탬프, 중요도 점수, 그리고 Vectorize에 넣은 정확한 벡터 ID 등을 포함한 구조화된 엔트리 데이터를 저장합니다. Vectorize는 UUID로 연결된 임베딩을 보관합니다.

export interface Env {
  DB: D1Database;
  VECTORIZE: VectorizeIndex;
  AI: Ai;
  AUTH_TOKEN: string;
}

Vectorize를 콘텐츠에 대한 진실된 원본(source of truth)으로 여기지 마세요. 이는 조회 인덱스로만 사용됩니다. D1이 데이터베이스 역할을 합니다. 이 구분은 데이터를 업데이트하거나 삭제할 때 매우 중요합니다.

문장 경계에서 청크 나누기, 문자 수가 아니라

긴 엔트리는 임베드하기 전에 나눕니다. 문자 수만 기준으로 나누면 의미적 컨텍스트가 손실될 수 있습니다. 해결책은 나눌 지점을 결정하기 전에 가장 가까운 문장 끝이나 줄바꿈을 찾아 뒤로 돌아가는 것입니다.

function chunkText(text: string, maxChars = 1600, overlapChars = 200): string[] {
  if (text.length  start + maxChars / 2) end = breakPoint + 1;
    }
    chunks.push(text.slice(start, Math.min(end, text.length)).trim());
    start = end - overlapChars;
  }
  return chunks.filter((c) => c.length > 0);
}

각 청크는 parentId 메타데이터에 D1 엔트리와 연결된 자체 Vectorize 벡터를 갖습니다. 반환된 정확한 벡터 ID를 추적하고 D1에 저장해야 합니다. Vectorize는 delete where parentId = x 같은 연산을 지원하지 않기 때문입니다.

await env.DB.prepare(
  `UPDATE entries SET vector_ids = ? WHERE id = ?`
).bind(JSON.stringify(vectorIds), id).run();

하나의 Vectorize 쿼리, 세 가지 판단

각 쓰기 작업마다 단일 임베드와 Vectorize 쿼리로 중복 감지, 모순 감지, 병합 결정을 한 번에 처리합니다. 세 가지 점수 구간이 다음 단계를 결정합니다.

const DUPLICATE_BLOCK_THRESHOLD = 0.95;
const DUPLICATE_FLAG_THRESHOLD = 0.85;
const CANDIDATE_SCORE_THRESHOLD = 0.45;
  • >= 0.95: 정확하거나 거의 정확한 중복. 이를 차단하고 LLM 호출을 완전히 건너뜁니다.
  • 0.85 ~ 0.95: 충분히 유사해 판단이 필요합니다. LLM에 네 가지 행동 중 하나를 선택하도록 프롬프트를 보냅니다: contradiction, replace, merge, keep_both.
  • 0.45 ~ 0.85: 모순 후보만 해당됩니다. 병합 로직 없이 가벼운 프롬프트를 사용합니다.

플래그된 구간에서 사용되는 결합 프롬프트는 다음과 같습니다.

Choose exactly one action. Prioritise in this order:
1. "contradiction" — new memory DIRECTLY CONFLICTS with an existing one
2. "replace" — new memory clearly supersedes an existing one
3. "merge" — both memories are complementary and better as one combined entry
4. "keep_both" — memories are different enough to coexist

Respond with JSON only.
{"action":"keep_both"} OR
{"action":"contradiction","conflicting_id":"","reason":""} OR
{"action":"replace","target_id":""} OR
{"action":"merge","target_id":"","merged_content":""}

LLM이 반환한 ID가 후보 집합에 포함되는지 반드시 검증하세요. LLM은 잘못된 ID를 생성할 수 있습니다.

오래된 벡터 정리는 선택 사항이 아니다

병합이 발생하면 새로운 정규 엔트리를 쓰고 기존 두 엔트리를 삭제합니다. D1에서의 삭제는 간단하지만, Vectorize에서 삭제하려면 앞서 저장해 둔 정확한 ID가 필요합니다.

if (oldVectorIds.length) await env.VECTORIZE.deleteByIds(oldVectorIds);

이 과정을 건너뛰면 고아 벡터가 조용히 누적됩니다. 이들은 검색 결과에 나타나 점수를 부풀리고, D1에 더 이상 존재하지 않는 엔트리를 가리키는 매치를 만들게 됩니다. 문제를 진단하기는 어렵지만 예방은 쉽습니다.

업데이트의 경우, 새로운 벡터를 먼저 삽입하고 성공을 확인한 뒤 기존 벡터를 삭제하는 것이 안전합니다. 삭제 후 삽입이 실패하면 데이터가 손실될 수 있습니다.

코사인 유사도만으로는 재랭킹에 부족하다

Vectorize의 원시 점수는 코사인 유사도를 기반으로 합니다. 엔트리의 연령과 접근 빈도가 달라지면 이는 관련성을 제대로 측정하지 못합니다. 재랭커는 세 가지 가중치를 적용합니다.

  • 최근성: 태그별 반감기를 고려한 지수 감쇠. 작업은 7일, 업무 엔트리는 3개월, 컨텍스트 엔트리는 6개월마다 감소합니다.
  • 빈도: log1p를 사용해 호출 횟수를 보정, 자주 접근된 엔트리가 새 엔트리를 압도하지 않으면서도 상위에 오를 수 있게 합니다.
  • 중요도: 쓰기 시 별도의 LLM 패스를 통해 15 점을 부여하고, 0.881.20 사이의 가중치로 스케일링합니다. 높은 중요도 엔트리는 최근성 상한을 넘어설 수 있습니다.
const recencyMultiplier = Math.exp(-ageMs / halfLifeMs);
const frequencyMultiplier = 1 + Math.log1p(rc);
const importanceMultiplier = imp === 0 ? 1.0 : 0.8 + (imp / 5) * 0.4;

return {
  ...match,
  score: match.score * recencyMultiplier * frequencyMultiplier * importanceMultiplier
};

짧은 추가 내용이나 롤업된 엔트리는 상위 결과에 노이즈가 섞이지 않도록 페널티가 적용됩니다.

topK 배수 문제

parentId 기준으로 중복을 제거하기 전에 topK 값을 최소 3배로 늘리세요. 하나의 엔트리가 4개의 청크를 가지고 topK: 5 로 쿼리하면, 그 4개의 청크가 대부분의 결과 슬롯을 차지해 고유한 부모를 충분히 볼 수 없습니다.

const VECTORIZE_TOP_K_MULTIPLIER = 3;

먼저 더 넓게 쿼리하고, 애플리케이션 코드에서 parentId 로 중복을 제거하세요.

전체 구현은 오픈 소스로 제공됩니다. 관심 있는 주제를 직접 살펴보고 싶다면 다음 저장소를 확인해 보세요: github.com/rahilp/second-brain-cloudflare

0 조회
Back to Blog

관련 글

더 보기 »

모바일 한여름 열풍

!Cover image for Mobile Midsommer Madnesshttps://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploa...