문제
OpenCairn의 RAG는 그냥 "문서 몇 개 찾아서 LLM에 넣기"가 아닙니다.
처음에는 저도 RAG를 검색 알고리즘 쪽으로만 생각했습니다. embedding을 만들고, vector search를 하고, top-k chunk를 prompt에 넣으면 된다고 봤습니다. 그런데 팀 지식 OS에서는 이게 바로 부족해집니다.
OpenCairn의 기본 경계는 대략 이렇습니다.
Workspace
-> Project
-> Page / Note / File / Import
workspace가 큰 isolation boundary이고, 그 아래에서도 project/page/note 권한이 다시 갈릴 수 있습니다. 사용자가 어떤 note를 읽을 수 없다면, 그 note는 검색 결과에도 나오면 안 되고, citation에도 나오면 안 되고, provider reranker의 후보에도 들어가면 안 됩니다.
제가 피하고 싶었던 구조는 이것입니다.
const chunks = await vectorSearch(queryEmbedding, { workspaceId });
const answer = await llm.generate({ query, context: chunks });
개인 실험에서는 빠르게 동작하지만, 실제 제품에서는 위험합니다. 사용자가 볼 수 없는 자료가 prompt context에 들어가는 순간, 답변 UI에서 숨기는 것은 이미 늦습니다.
현재 코드의 중심
현재 OpenCairn RAG의 중심 파일은 apps/api/src/lib/chat-retrieval.ts입니다.
여기서 공개되는 핵심 타입은 이쪽에 가깝습니다.
export type RagMode = "strict" | "expand" | "off";
export type RetrievalScope =
| { type: "workspace"; workspaceId: string }
| { type: "project"; workspaceId: string; projectId: string }
| { type: "page"; workspaceId: string; noteId: string };
export type RetrievalChip =
| { type: "page"; id: string }
| { type: "project"; id: string }
| { type: "workspace"; id: string };
예전 글에는 auto나 focused처럼 더 추상적인 이름으로 적어둔 부분이 있었는데, 지금 코드 기준으로는 strict, expand, off가 맞습니다.
흐름을 줄이면 이렇습니다.
user query
-> resolve scope/chips into readable project ids
-> embed query
-> collect per-project hits
-> note_chunks hybrid search
-> fallback note hybrid search
-> graph expansion if policy allows
-> canRead filter
-> rerank
-> retrieval quality report
-> corrective retry if expand mode allows it
-> optional provider rerank
-> context/citation hits
여기서 중요한 점은 RAG가 route handler 옆에 붙은 작은 helper가 아니라는 것입니다. retrieveWithPolicy()는 검색 결과뿐 아니라 policy summary와 quality report까지 같이 반환합니다.
strict와 expand
OpenCairn에서 strict와 expand는 검색 품질 옵션이면서 동시에 권한 경계입니다.
strict는 사용자가 붙인 page/project/workspace chip과 현재 scope를 벗어나지 않습니다. 검색 결과가 약해도 마음대로 workspace 전체를 뒤지지 않습니다.
expand는 더 넓게 찾을 수 있지만, "같은 scope 안에서" 넓힙니다. top-k를 늘리고, graph depth를 켜고, quality가 약하면 corrective retry를 한 번 할 수 있습니다. 하지만 사용자가 읽을 수 없는 project나 note로 넘어가는 fallback은 아닙니다.
strict
-> attached scope 안에서만 검색
-> 약하면 약하다고 말해야 함
expand
-> 같은 scope 안에서 seedTopK / graphDepth / graphLimit 확장
-> sparse evidence면 graphDepth=1 retry 가능
-> 권한 밖 fallback은 아님
off
-> retrieval resultTopK = 0
이 구분이 중요했습니다. RAG 품질을 올리려고 자동으로 범위를 넓히면, 사용자는 "내가 이 자료를 물어본 적이 없는데 왜 저 자료를 근거로 답하지?"라고 느낄 수 있습니다. 팀 제품에서는 그 순간 신뢰가 깨집니다.
note_chunks가 먼저다
현재 검색은 note 전체보다 note_chunks를 먼저 봅니다.
packages/db/src/schema/note-chunks.ts를 보면 chunk는 단순 text 조각이 아닙니다.
{
workspaceId,
projectId,
noteId,
chunkIndex,
headingPath,
contextText,
contentText,
contentTsv,
embedding,
tokenCount,
sourceOffsets,
contentHash,
}
이 구조가 있어야 검색 결과가 "어떤 note"가 아니라 "어떤 근거 조각"으로 내려옵니다. citation, evidence bundle, agentic plan freshness를 생각하면 이 차이가 큽니다.
실제 hybrid search는 apps/api/src/lib/chunk-hybrid-search.ts에서 pgvector와 BM25를 같이 씁니다.
vector search
-> 1 - (embedding <=> queryEmbedding)
BM25 search
-> ts_rank(content_tsv, plainto_tsquery(...))
fusion
-> reciprocal rank fusion
chunk hit가 있으면 그걸 seed로 쓰고, 없으면 note-level hybrid search로 fallback합니다. 이 fallback은 "검색을 포기하지 않기 위한 fallback"이지, 권한을 우회하는 fallback이 아닙니다.
graph expansion은 품질과 위험을 같이 만든다
OpenCairn에는 wiki note와 concept graph가 있습니다. 그래서 관련 자료를 찾을 때 단순 vector match만 쓰면 부족할 때가 있습니다.
apps/api/src/lib/retrieval-graph-expansion.ts는 seed note에서 concept graph를 따라가며 관련 note/chunk 후보를 만듭니다.
seed note
-> concept_notes
-> concepts
-> concept_edges
-> related notes
-> related note_chunks
이 단계는 검색 품질을 올립니다. 하지만 동시에 위험합니다. graph를 따라가면 사용자가 직접 선택하지 않은 자료까지 후보로 들어올 수 있습니다.
그래서 graph expansion hit도 다시 canRead()를 통과합니다. 실제 chat-retrieval.ts는 graph hit를 ProjectRetrievalHit으로 변환한 뒤 readable hit만 남깁니다.
graph expansion
-> map to retrieval hit
-> canRead(note)
-> only then rank/context
저는 이 순서가 RAG 제품에서 생각보다 중요하다고 느꼈습니다. "마지막 답변에만 안 넣으면 되지"가 아니라, candidate pipeline 전체에서 private data를 다루는 위치를 줄여야 합니다.
quality report와 corrective retry
RAG가 늘 좋은 근거를 찾지는 못합니다.
그래서 OpenCairn은 retrieval result를 바로 prompt에 넣기 전에 quality를 봅니다. apps/api/src/lib/retrieval-quality.ts는 후보가 비어 있는지, sparse한지, confidence가 너무 낮은지 같은 신호를 보고 report를 만듭니다.
중요한 점은 retry도 정책을 지킨다는 것입니다.
initial retrieval
-> evaluate quality
-> if sparse/weak and mode is expand
-> same-scope graphDepth=1 corrective retry
-> merge final quality reasons
strict에서는 corrective retry가 scope를 넓히는 수단이 되면 안 됩니다. 그래서 weak evidence면 weak evidence로 남겨야 합니다.
이 설계를 하면서 RAG 품질은 "무조건 답을 만들어내기"가 아니라는 생각이 더 강해졌습니다. OpenCairn에서는 근거가 약하면 약하다는 사실도 제품 상태입니다.
provider rerank는 마지막 옵션이다
OpenCairn은 deterministic rerank를 기본으로 두고, CHAT_RAG_RERANKER=provider가 켜졌을 때만 provider rerank를 붙일 수 있게 합니다.
LLM reranker가 좋아 보일 때도 있지만, 제품 경계에서는 조심해야 합니다.
- provider가 실패할 수 있다.
- 비정상 JSON을 줄 수 있다.
- 후보 순서를 바꾸더라도 권한 밖 후보를 만들면 안 된다.
- citation과 evidence id는 그대로 추적 가능해야 한다.
그래서 provider rerank는 readable candidate set 위에서만 동작하고, 실패하면 heuristic order로 돌아갑니다.
RAG가 chat만의 기능이 아닌 이유
OpenCairn에서 RAG는 chat 답변만 위한 기능이 아닙니다.
같은 evidence layer가 다음 기능으로 이어집니다.
chat answer
-> citation
-> document generation sources
-> note.update evidence
-> agentic plan freshness
-> workflow recovery
특히 agentic workflow에서는 검색 결과가 오래됐는지가 실행 가능 여부에 영향을 줍니다. 노트가 바뀌었는데 예전 chunk를 근거로 plan step을 실행하면 안 됩니다. 그래서 RAG와 note analysis job, agentic plan evidence가 서로 붙기 시작했습니다.
구현하며 배운 점
처음에는 RAG를 검색 품질 문제로 봤습니다. 지금은 조금 다르게 봅니다.
OpenCairn에서 RAG는 다음을 동시에 만족해야 합니다.
- 사용자가 읽을 수 있는 자료만 후보가 된다.
- chunk 단위로 citation 가능한 evidence를 만든다.
- graph expansion을 하되 권한 경계를 유지한다.
- stale evidence를 구분한다.
- weak evidence면 답변을 줄이거나 recovery path로 넘긴다.
- chat, document generation, agentic workflow가 같은 evidence boundary를 공유한다.
그래서 지금 제가 생각하는 OpenCairn식 RAG의 정의는 이렇습니다.
RAG = retrieval + permission + freshness + evidence + recovery
embedding model이나 reranker보다 먼저 이 경계가 있어야, RAG가 제품 기능으로 오래 버틸 수 있다고 봅니다.