본문으로 건너뛰기

인제스트 파이프라인

업로드되거나 커넥터가 가져온 문서는 Validate → Parse → Dedup → Chunk → Embed → Upsert → Meta 7단계를 거쳐 검색 가능한 상태가 된다. 단계 구성 자체(아키텍처)와 각 단계가 어느 저장소를 건드리는지(인제스트 흐름)는 개념 문서에서 다룬다. 여기서는 각 단계를 실제로 조정하는 settings.yaml 키와 그 효과를 정리한다.

ingestion / chunking / dedup 세 섹션은 KB별로 오버라이드할 수 있다(검색 설정 retrieval은 auto_merge만 같은 방식으로 오버라이드 가능 — 검색 — Auto-merge 참고). 반면 provider / embedding은 오버라이드 대상이 아닌 전역 전용 설정이다 — 임베딩 모델이 KB마다 다르면 Qdrant 컬렉션의 벡터 차원이 KB마다 달라져야 하기 때문이다.


Validate 단계​

업로드되거나 커넥터가 가져온 파일이 max_file_size_mb를 넘으면 파이프라인에 들어가기 전에 거부한다.

ingestion:
max_file_size_mb: 10 # 1-1024

파일 크기가 이 값을 초과하면 큐 소비 직후, parse로 넘어가기 전에 IngestValidationError (422)로 거부된다. 커넥터가 가져온 문서도 동일하게 적용된다 — 크기 제한은 소스와 무관하게 파이프라인 레벨에서 한 번만 걸린다.

Parse 단계​

HTML 문서에서 본문을 얼마나 보수적으로 추출할지, 그리고 새 파일 형식을 코드 수정 없이 어떻게 등록할지를 정한다.

ingestion:
html_extraction_policy: "lenient" # strict | lenient | balanced
parser_plugins: []

HTML 추출 정책​

html_extraction_policy는 HTML 문서(웹/Confluence 커넥터 산출물 포함)에서 본문을 추출할 때 애매한 블록을 어떻게 다룰지 정한다.

값동작
strict애매한 블록은 제외 — 짧지만 확실한 본문만 남긴다
lenient애매한 블록도 포함 — 본문 손실은 최소화되지만 짧은 boilerplate가 섞일 수 있다
balanced두 정책의 중간

파서 플러그인 확장​

parser_plugins는 확장자별 리더를 코드 수정 없이 추가·교체하는 확장점이다. 기본으로 문서류 17종 리더(PDF/Office/HWP/HTML/텍스트 등)와 코드·설정 파일 확장자를 합쳐 36개 확장자가 등록되어 있으며, 등록·교체 메커니즘 자체는 파서 레지스트리에서 다이어그램으로 설명한다. 실제로 확장하려면 register(ext, reader) 시그니처를 따르는 함수를 만들고 그 경로를 나열하면 된다.

ingestion:
parser_plugins:
- "my_plugins.readers:register"

기동 시 목록 순서대로 호출되며, 같은 확장자를 여러 플러그인이 등록하면 나중에 실행된 쪽이 이긴다(last-writer-wins). parser_plugins 자체는 배포 타임에 고정되는 설정이라 KB별 오버라이드 대상에서 제외된다.

Dedup 단계​

새 문서 A를 기존 문서와 비교해 완전 동일/제목 변경/유사 여부를 저비용 검사부터 순서대로 판정한다.

dedup:
enabled: true
simhash: { hamming_identical_threshold: 2, hamming_similar_threshold: 5 }
minhash: { jaccard_threshold: 0.65, title_fuzzy_threshold: 0.85 }
chunk_compare: { chunk_match_threshold: 0.50, body_identical_threshold: 0.95, body_similar_threshold: 0.75 }

dedup.enabled: false면 이 단계 전체를 건너뛰고 바로 chunk로 넘어간다. 코드/설정 파일 확장자(.py, .yaml 등)는 dedup 대상이 아니다 — 산문 본문 비교이므로 PDF/Office/HTML 같은 문서류 확장자에만 실행된다.

탐지 흐름​

앞 차수가 후보를 못 찾으면 다음 차수로 넘어가고, similar 후보까지만 나오면 3차가 그 후보 하나를 정밀 비교해 최종 확정하는 깔때기 구조다.

new document A
│
▼
stage 1: SimHash — title SHA-256 exact match + body Hamming distance
│
├─ distance <= hamming_identical_threshold ──► identical_level (title: same/changed)
├─ distance <= hamming_similar_threshold ──► similar candidate
└─ no candidate
│
▼
stage 2: MinHash + title fuzzy match — only if stage 1 found nothing
│
├─ Jaccard >= jaccard_threshold, or
│ (title_sim >= title_fuzzy_threshold and Jaccard >= title_only_min_jaccard_floor)
│ ──► similar candidate
└─ otherwise ──► none → index normally
│
▼ (only for a similar candidate, from stage 1 or 2)
stage 3: chunk-level comparison — Top-1 match per chunk vs the
candidate's chunks in Qdrant, coverage-weighted average x chunk_ratio
│
├─ score >= body_identical_threshold ──► identical_level (title recomputed)
├─ score >= body_similar_threshold ──► similar
└─ score < body_similar_threshold ──► none → index normally

차수별 동작​

  • 1차 — SimHash: body Hamming distance와 title SHA-256 완전 일치를 비교한다. 저비용 근사 비교라 대부분의 신규 문서가 여기서 후보 없음으로 끝나며, identical_level을 그 자리에서 직접 확정할 수 있는 유일한 차수다(이때 title_match도 함께 결정된다).
  • 2차 — MinHash: 1차가 후보를 못 찾았을 때만 실행되는 보조 필터로, Jaccard 유사도(128개 MinHash 시그니처)와 제목 fuzzy match로 similar 후보만 걸러낸다. 근사치라 완전 동일 여부까지는 판정하지 않는다.
  • 3차 — chunk 비교: 1·2차가 넘긴 similar 후보 하나(기본값, compare_all_candidates: true면 전체 후보)에 대해서만 Qdrant 검색과 임베딩 계산을 동반하는 정밀 비교를 수행한다. title_match도 이 차수에서 최종 후보를 대상으로 다시 계산한다 — 1차에서 확정된 경우가 아니면 "미확인" 상태로 남아 있어서, 그대로 두면 제목이 실제로 바뀐 문서까지 "완전 동일"로 오판되기 때문이다.

3차 집계 점수​

A의 전체 청크 수 N을 분모로 고정한 커버리지 가중 평균이다.

raw_score(A, C) = (매칭된 청크들의 Qdrant Top-1 점수 합) / N
chunk_ratio = min(A 청크 수, C 청크 수) / max(A 청크 수, C 청크 수)
score(A, C) = raw_score(A, C) * chunk_ratio

매칭 안 된 청크도 0점으로 분모에 남기 때문에 "몇 개 중 몇 개가 겹쳤는지"가 그대로 점수에 반영된다. chunk_ratio는 짧은 문서가 훨씬 긴 문서의 일부와만 겹쳐도 raw_score가 1.0에 가깝게 나오는 문제(포함 관계를 동일 문서로 오판)를 보정한다 — 두 문서의 청크 수 차이가 클수록 최종 점수를 깎는다.

판정 결과 처리​

최종 판정은 doc_created_at이 더 최신인 문서가 이기는 방식으로 문서 status를 바꾼다.

판정결과
none정상 색인 진행
identical_level + title 동일A → outdated(duplicate_of=기존 문서), 기존 문서는 변경 없음
identical_level + title 변경, A가 최신기존 문서 청크의 Qdrant payload(doc_id/title/source)만 A로 이전(재임베딩 없음), 기존 문서 → outdated, A → indexed
identical_level + title 변경, 기존 문서가 최신A → outdated만, 기존 문서는 변경 없음
similar, A가 최신기존 문서의 Qdrant 청크를 삭제하고 → outdated, A는 표준 경로로 재색인
similar, 기존 문서가 최신A → outdated(색인하지 않음), 기존 문서는 변경 없음

상태 전이 관점은 dedup 판정과 outdated 전이 참고.

임계값 튜닝​

차수필드범위의미
SimHashhamming_identical_threshold0‑19이 값 이하 Hamming 거리 → identical_level
SimHashhamming_similar_threshold0‑19이 값 이하 거리(위 임계값 초과분) → similar 후보. 이 값도 넘으면 후보에서 제외
MinHashjaccard_threshold0.0‑1.0128개 MinHash 시그니처 중 일치 비율이 이 값 이상이면 similar
MinHashtitle_fuzzy_threshold0.0‑1.0제목 유사도가 이 값 이상이고 Jaccard가 title_only_min_jaccard_floor 이상이면 similar(본문 유사도가 낮아도 제목만으로 후보 인정)
chunk 비교chunk_match_threshold0.0‑1.0청크 단위 임베딩 코사인 유사도가 이 값 이상인 청크만 매칭으로 인정
chunk 비교body_identical_threshold0.0‑1.0집계 점수가 이 값 이상 → identical_level
chunk 비교body_similar_threshold0.0‑1.0집계 점수가 이 값 이상 → similar

임계값은 올릴수록 "유사" 판정 폭이 넓어져 과다 탐지 위험이, 낮출수록 후보가 좁아져 누락 위험이 커진다. simhash.ngram/num_bands/simhash_bits와 minhash.user_words_path는 기존 문서 지문과 계산 방식이 어긋나면 안 되므로 KB별 오버라이드 대상에서 제외된다.

Chunk 단계​

파싱된 문서를 얼마나 잘게 나눠 임베딩할지 정한다 — 일반 문서는 recursive/semantic/ hierarchical 중 선택하고, 코드 파일은 항상 전용 분할기를 쓴다.

chunking:
strategy: "recursive" # recursive | semantic | hierarchical
chunk_size: 1024 # 64-8192 (hierarchical이면 내림차순 리스트, 예: [2048, 512, 128])
chunk_overlap: 128 # 0-8191, leaf(가장 작은 chunk_size) 대비 15% 이내
min_chunk_chars: 30 # 1-2000
semantic_threshold: 0.8 # 0.0-1.0, semantic 전략에서만 사용
code_max_chars: 1500 # 100-20000

청킹 전략​

전략동작
recursiveSentenceSplitter — chunk_size/chunk_overlap 글자 수 기준 분할. 추가 API 호출 없음
semanticSemanticSplitterNodeParser — 문장 임베딩 유사도가 semantic_threshold(백분위) 밑으로 떨어지는 지점에서 분할. 청킹 단계에서 임베딩 모델을 호출하므로 recursive보다 느리고 비용이 든다
hierarchicalHierarchicalNodeParser — root부터 leaf까지 여러 레벨로 분할해 parent-child 관계를 저장한다. leaf만 임베딩·검색 대상이고, 검색 시 매칭된 child 비율이 임계값 이상이면 parent 텍스트로 자동 병합해 반환할 수 있다 — 검색 쪽 동작은 검색 — Auto-merge 참고

분할 후 min_chunk_chars보다 짧은 청크는 (코드/atomic 청크를 포함해) 모두 버려진다 — 문장 경계가 애매해 생기는 한두 글자짜리 파편이 검색 인덱스에 섞이는 것을 막기 위한 하한선이다. hierarchical에서는 이렇게 버려진 leaf가 parent의 자식 수 계산에서도 제외된다.

hierarchical 전략​

chunking:
strategy: "hierarchical"
chunk_size: [2048, 512, 128] # root -> mid -> leaf, 반드시 내림차순, 최소 2레벨
chunk_overlap: 16 # leaf(128)의 15% 이내 — 초과하면 저장 시점에 거부

chunk_overlap은 leaf 레벨에만 적용된다(root/mid는 겹치지 않음) — HierarchicalNodeParser가 레벨별로 다른 overlap을 지원하지 않기 때문이다. 값이 가장 작은 chunk_size(leaf)의 15%를 넘으면 인접 leaf가 거의 통째로 겹치는 상태로 조용히 색인되는 문제가 있어, 전역 설정 로드와 KB 오버라이드 저장 양쪽 모두에서 거부된다. 이미 인덱싱된 문서는 전략을 바꿔도 소급 재청킹되지 않는다 — 이후 새로 업로드/재인덱싱하는 문서부터 적용된다.

코드 파일 처리​

strategy는 코드/설정 파일이 아닌 일반 문서에만 적용된다 — .py/.ts/.yaml 같은 확장자는 strategy 설정과 무관하게 항상 CodeSplitter로 자동 라우팅되고, code_max_chars (문자 수 기준)로 크기를 조절한다.

Embed 단계​

청크마다 dense 벡터(의미 검색용)와 sparse 벡터(키워드 검색용)를 계산해 주입한다.

provider:
name: "ollama" # ollama | openai
ollama_url: "http://localhost:11434"
openai_api_key: ""

embedding:
model: "bge-m3" # ollama: bge-m3 / openai: text-embedding-3-small
vector_size: 1024 # bge-m3=1024, text-embedding-3-small/ada-002=1536

프로바이더와 모델​

Dense 임베딩은 provider.name이 가리키는 프로바이더(Ollama 또는 OpenAI)로 계산되고, Sparse(BM25) 임베딩은 FastEmbed로 별도 계산되어 두 벡터가 asyncio.gather로 병렬 처리된다.

vector_size 주의사항​

vector_size는 실제 모델의 출력 차원과 반드시 일치해야 한다 — Qdrant 컬렉션은 KB가 처음 쓰일 때 이 값으로 생성되고, 이후 vector_size를 바꾸면 기존 컬렉션의 차원과 어긋나 ConfigError(500)로 즉시 실패한다. 이미 문서가 색인된 KB에서 임베딩 모델을 바꾸려면 컬렉션을 새로 만들고 전체 문서를 재인덱스해야 한다.

Upsert·Meta 단계​

Qdrant에 청크를 반영하고 문서 상태를 갱신하는 마지막 단계로, 별도로 조정할 설정은 없다.

upsert_op은 Qdrant에서 해당 doc_id의 기존 청크를 지운 뒤 새 청크를 넣는 방식으로 동작해 재인덱스가 그대로 멱등하게 적용되고, meta_op이 문서 status를 indexed로 바꾸며 etag/chunk_count/updated_at을 기록한다. 색인된 청크를 어떻게 검색하고 리랭킹하는지는 검색 가이드에서 다룬다.