Skip to content

feat: pluggable embedding backends (OpenAI-compatible / local-gguf + resilient fallback)#7

Open
epruseal wants to merge 8 commits into
AlexAI-MCP:mainfrom
epruseal:feat/embedding-backends
Open

feat: pluggable embedding backends (OpenAI-compatible / local-gguf + resilient fallback)#7
epruseal wants to merge 8 commits into
AlexAI-MCP:mainfrom
epruseal:feat/embedding-backends

Conversation

@epruseal

@epruseal epruseal commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

feat: pluggable embedding backends (OpenAI-compatible / local-gguf + resilient fallback)

배경 / 문제

ChromaDB 기본 임베딩 함수(all-MiniLM-L6-v2, ONNX, 384d)는 영어 특화라 한국어 검색 품질이 낮다.
동일 코퍼스에서 실측 결과:

백엔드 top-1 MRR
minilm (기본, 384d) 0/5 0.285
KURE-v1 (1024d) 5/5 1.000

변경 요약

EMBEDDING_BACKEND 환경변수로 임베딩 함수를 교체할 수 있게 한다. 기본값은 local이라
기존 동작 100% 보존(아무 설정도 안 하면 지금과 똑같이 minilm 사용).

  • EMBEDDING_BACKEND=local (기본): ChromaDB 기본 EF. 기존 컬렉션 opencrab_vectors 그대로.
  • EMBEDDING_BACKEND=openai: OpenAI 호환 /v1/embeddings API를 쓰는 범용 경로. 임베딩
    모델은 자유롭게 선택 — 실제 OpenAI 클라우드 모델(text-embedding-3-small/-large 등)도,
    로컬·자체호스팅 서버(LM Studio·Ollama·vLLM·HF TEI)에 올린 모델도 모두 가능하다. 모델은
    OPENAI_EMBED_MODEL, 차원은 EMBED_DIM, 컬렉션은 EMBED_COLLECTION으로 지정한다.
    • 추천 모델(한국어): 자체호스팅이라면 KURE-v1(1024d, BGE-M3 기반, 한국어 SOTA)을
      추천한다. 번들된 로컬 GGUF 폴백도 KURE-v1이라, KURE primary면 추가 설정 없이 폴백까지
      바로 동작한다.
    • 클라우드 OpenAI 모델을 쓸 경우 EMBED_DIM을 해당 모델 차원에 맞추고(예: -3-small=1536),
      로컬 GGUF 폴백은 차원이 달라 부적합하므로 LOCAL_GGUF_PATH로 동일 차원 GGUF를 지정하거나
      폴백에 의존하지 않는다(원격만 사용). → 자세한 제약은 아래 "임베딩 모델 혼용 금지" 참고.

신규 컴포넌트

파일 역할
stores/openai_embedding.py OpenAIEmbeddingFunction — httpx로 /v1/embeddings 직접 호출. OPENAI_API_KEY 설정 시 Authorization: Bearer, 미설정 시 무인증(LM Studio 등). L2 재정규화.
stores/llamacpp_embedding.py LlamaCppEmbeddingFunction — 로컬 GGUF 폴백. lazy load, 미존재 시 HuggingFace 자동 다운로드(KURE-v1-Q4_K_M).
stores/resilient_embedding.py ResilientEmbeddingFunction — primary 장애 시 fallback 자동 전환. health TTL 캐시로 불필요한 타임아웃 재시도 방지.

기존 파일 변경

  • config.py: 임베딩 관련 Settings 필드 추가(아래 환경변수). 기존 필드/동작 불변.
  • stores/chroma_store.py: embedding_function: Any = None 파라미터 추가. None이면 기존
    기본 EF 사용 → 하위호환. 주입 시 해당 EF로 add/query.
  • stores/factory.py: make_vector_store()embedding_backend=="openai" 분기만 추가.
    make_graph_store/make_doc_store/make_sql_store는 변경 없음.

호환성 / 롤백

  • 기본값 local이라 설정 안 하면 기존과 동일. 새 의존성도 openai 경로에서만 로드(lazy import).
  • EMBEDDING_BACKEND 미설정 또는 local로 되돌리면 즉시 롤백, 기존 컬렉션 보존.
  • name()="kure_v1" 고정 → 컬렉션 메타데이터 일관 → 재시작/폴백 전환 시 동일 컬렉션 재사용.

⚠️ 임베딩 모델 혼용 금지

하나의 Chroma 컬렉션은 하나의 임베딩 모델·차원으로만 채워야 한다. 색인(add)과
질의(query)는 반드시 같은 모델로 수행되어야 하며, 서로 다른 모델의 벡터를 한 컬렉션에
섞으면 검색이 무의미해진다(차원 불일치 또는 의미공간 불일치). 한 컬렉션 = 한 모델
원칙이다. (openai는 모델이 아니라 *백엔드(전송 방식)*임에 유의 — 실제 OpenAI 모델, KURE,
KoSimCSE 등 어떤 모델이든 이 백엔드로 쓸 수 있고, 그중 무엇을 골랐든 컬렉션 안에서 섞지 말 것.)

  • local(minilm 384d)과 openai 백엔드의 컬렉션은 분리되어 있어
    (opencrab_vectorsEMBED_COLLECTION, 기본 opencrab_vectors_kure) 자동으로 충돌하지 않는다.
  • openai 백엔드 안에서도 모델을 바꾸면(예: KURE 1024d → KoSimCSE 768d) 반드시 새
    EMBED_COLLECTION을 쓰고 전량 재색인
    해야 한다. 기존 컬렉션에 다른 모델로 덧쓰면 안 된다.
  • primary(원격)와 fallback(로컬 GGUF)은 동일 모델·차원이어야 한다. ResilientEF는 둘이
    같은 KURE-v1임을 전제로 name()="kure_v1" 하나로 컬렉션을 공유한다. 서로 다른 모델을
    primary/fallback으로 두면 안 된다.

환경변수

변수 기본값 설명
EMBEDDING_BACKEND local local 또는 openai
OPENAI_API_BASE http://localhost:1234/v1 OpenAI 호환 서버 base URL
OPENAI_EMBED_MODEL text-embedding-kure-v1 임베딩 모델 id. 서버/제공자가 인식하는 값(예: text-embedding-3-small, KURE 서빙 모델명 등)
OPENAI_API_KEY (빈 값) 설정 시 Bearer 인증, 미설정 시 무인증
OPENAI_TIMEOUT 8.0 HTTP 타임아웃(초)
EMBED_DIM 1024 임베딩 차원. 선택한 모델에 맞춰야 함(KURE=1024, text-embedding-3-small=1536)
EMBED_COLLECTION opencrab_vectors_kure openai 백엔드 컬렉션. 모델·차원별로 분리할 것
LOCAL_GGUF_PATH (빈 값) 폴백 GGUF 경로. 미설정 시 ~/.cache/opencrab/models에 자동 다운로드

테스트 / 검증

  • py_compile 6파일 통과.
  • import 스모크: OpenAIEmbeddingFunction.name()=="kure_v1", Bearer 헤더 분기, _l2_normalize,
    빈입력 패스스루, ResilientEF 체인 생성 모두 정상(네트워크 없이).
  • _DEFAULT_GGUF_DIR~/.cache/opencrab/models로 해석되고 XDG_CACHE_HOME 존중 확인.
  • 운영 환경(localcrab) 실측: KURE 서버 ping OK, 임베딩 1024d, ontology_query 3건 정상

비고

  • 코드/주석/문서는 특정 벤더에 종속되지 않게 일반화("OpenAI 호환 서버"). LM Studio 언급은
    호환 서버의 예시로만 남김.
  • 폴백 기본 모델 mykor/KURE-v1-gguf(Q4_K_M, ~438MB)는 한국어 임베딩용 공개 모델. 다른 모델은
    LOCAL_GGUF_PATH로 지정 가능.
  • 경량 대안 (CPU 부담 시): KURE-v1은 BGE-M3 기반(약 560M, 1024d)이라 CPU만으로는 무겁다.
    CPU 자원이 부족하면 한국어 경량 임베딩 BM-K/KoSimCSE-roberta
    (RoBERTa-base, 약 110M, 768d)를 추천한다. KURE보다 가볍고 빠르지만 한국어 전용이며 검색
    품질은 다소 낮을 수 있다.
    • 이 PR이 머지되면 코드 수정 없이 사용 가능: OpenAI 호환 서버(예: HF
      Text-Embeddings-Inference)에 KoSimCSE를 서빙하고 OPENAI_EMBED_MODEL,
      EMBED_DIM=768, 별도 EMBED_COLLECTION만 지정하면 된다(전량 재색인 필요).
    • 로컬 GGUF 폴백 경로는 GGUF 빌드가 있어야 하므로 KoSimCSE에는 기본 적용되지 않는다
      (GGUF가 없으면 EMBEDDING_BACKEND=openai의 primary만 사용).

AlexAI-MCP and others added 8 commits May 13, 2026 14:22
…billing

Fix Docker full mode startup and billing events
## 문제

`LocalGraphStore.find_neighbors`의 외부 while 루프 조건
`len(results) < limit`은 노드 한 개를 완전히 처리한 **뒤에야** 평가된다.
기존 코드는 `cur.fetchall()`로 해당 노드의 모든 엣지를 한꺼번에 메모리에 올린
다음 내부 for 루프를 끝까지 돌았기 때문에, 그동안 limit 체크가 전혀 일어나지
않았다.

이로 인해 차수(degree)가 높은 허브 노드 — 예: 온톨로지 지식 그래프에서
"engineer", "persona", "pack" 같은 개념 노드 — 를 탐색할 때, limit(기본값 50)에
도달한 이후에도 수백~수천 회의 불필요한 `_fetch_node_props` SQL 쿼리가 실행됐다.

## 실측 수치

실제 온톨로지 데이터셋(nodes.jsonl 43,476개, edges.jsonl 68,322개)으로 측정:

| 스케일 | 최고 허브 차수 | find_neighbors d1 p50 |
|--------|-------------|----------------------|
| 20,000 노드 |  98차수 | **0.37ms** |
| 43,000 노드 | 615차수 | **11.86ms** ← 32× 급등 |

43,000 노드 시점에 차수 615짜리 허브가 등장하면서, 내부 루프가 ~1,230회
`_fetch_node_props` 쿼리를 모두 실행한 뒤에야 results가 50개를 채웠다.
데이터가 증가할수록 허브 차수도 함께 커지므로, 방치하면 선형 이상으로 열화된다.

## 수정 내용

두 단계 수정을 outgoing / incoming 양방향 모두에 적용했다:

1. **SQL `LIMIT ?`** — `fetchall`이 반환하는 행 자체를 남은 슬롯
   (`remaining = limit - len(results)`)으로 제한한다. 차수 615 허브라도
   이미 results가 0개인 경우 최대 50행만 DB에서 가져온다.

2. **내부 루프 `break`** — pack 필터 등으로 일부 행이 걸러지면
   fetchall로 가져온 `remaining`개 중 실제로 results에 추가되는 비율이
   낮아질 수 있다. SQL LIMIT만으로는 이를 보완할 수 없으므로, for 루프 안에서도
   limit 도달 시 즉시 break해 추가 property 조회를 방지한다.

## 수정 후 성능

동일 데이터셋(43k 노드, 차수 615 허브) 기준:

| depth | 수정 전 | 수정 후 | 개선 |
|-------|---------|---------|------|
| d=1 | 11.86ms | 1.59ms | **7.5×** |
| d=2 | 10.98ms | 1.58ms | **6.9×** |
| d=3 | 10.74ms | 1.56ms | **6.9×** |

## 동작 변경 없음 확인

- 결과 집합의 정확성: 기존 동작과 동일 (허브/일반 노드 모두 정확성 테스트 통과)
- limit 동작: 실제 차수 62인 노드에서 50개 반환 확인
- 일반 노드(차수 1-5): SQL LIMIT과 break 모두 발동하지 않으므로 동작 동일
- 결과 순서: SQLite는 엣지 삽입 순서 기반, Neo4j는 내부 인덱스 순서 기반으로
  이미 두 모드가 동일하지 않았으며, 이 수정으로 추가 변화 없음
Before: OntologyBuilder.add_edge resolved the from/to node types via
a Neo4j Cypher query, gated on `self._neo4j.available`. In local-only
mode the store is a LocalGraphStore (SQLite) and its `run_cypher` is a
no-op stub, so the lookup silently returned [] and both endpoints fell
back to `_space_to_default_type(space)` — flattening every subject-space
edge to `User`, every decision-space edge to the first decision type,
every resource-space edge to `Project`, and every concept-space edge to
`Entity`. Multi-type ontologies (e.g. domain packs that register many
node types per space) lost edge typing entirely in local mode, and
`find_neighbors` / `find_path` queries failed to find the typed neighbors
they expected.

After: both LocalGraphStore and Neo4jStore expose a parallel
`lookup_node_type(node_id) -> str | None`. The builder uses that method
via duck-typing on whichever store was injected, with the per-space
default kept only as a final fallback when the lookup truly returns None.

This restores typed-edge behavior for local-only deployments without
requiring Neo4j, and keeps existing Neo4j-backed deployments working
since the new Neo4jStore.lookup_node_type runs the same Cypher MATCH
that was previously embedded in the builder.

Discovered while building a domain ontology pack that registers many
typed decision/actor nodes in local mode; reproduced via the new
regression test using upstream-native Team / Document types.
fix: BFS find_neighbors 허브 노드 성능 문제 수정 (SQL LIMIT + 내부 루프 조기 종료)
…e-type-resolution

fix(builder): preserve real node types on edges in local-only mode
…resilient fallback)

기존 ChromaDB 기본 EF(all-MiniLM-L6-v2, 384d)는 영어 특화라 한국어 검색 품질이
낮다(실측 top-1 0/5, MRR 0.285). EMBEDDING_BACKEND 환경변수로 임베딩 함수를
교체할 수 있게 하여 KURE-v1(1024d) 등 OpenAI 호환 서버 모델로 품질을 개선한다
(실측 top-1 5/5, MRR 1.000).

- EMBEDDING_BACKEND=local(기본): 기존 ChromaDB EF 그대로 → 100% 하위호환.
- EMBEDDING_BACKEND=openai: OpenAI 호환 /v1/embeddings 서버(LM Studio·vLLM·
  Ollama·실제 OpenAI) + 로컬 GGUF 폴백.
- OpenAIEmbeddingFunction: httpx 직접 호출, OPENAI_API_KEY 설정 시 Bearer 인증.
- LlamaCppEmbeddingFunction: 로컬 GGUF 폴백(lazy load, 자동 다운로드).
- ResilientEmbeddingFunction: primary 장애 시 폴백 자동 전환(health TTL).

환경변수: EMBEDDING_BACKEND, OPENAI_API_BASE, OPENAI_EMBED_MODEL, OPENAI_API_KEY,
OPENAI_TIMEOUT, EMBED_DIM, EMBED_COLLECTION, LOCAL_GGUF_PATH.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants