# U1 Architecture Design: LLM Cache Core

> Status: APPROVED — Design reviewed, embedding model set to bge-m3 for Chinese-first
> Date: 2026-06-14
> Unit: U1 of P0 Production Hardening Plan

---

## 1. Design Goals

1. **Transparent caching**: `LLMGateway.chat()` callers cannot distinguish cached vs. uncached responses
2. **Dual-match strategy**: Exact-match (hash) for deterministic hits + Semantic-match (embedding) for paraphrased hits
3. **Backend pluggability**: `InMemoryLLMCache` for dev, `RedisLLMCache` for production, via factory
4. **Chinese-first embedding**: Default embedding model optimized for Chinese+English mixed text, with configurable fallback

---

## 2. Component Architecture

```
┌──────────────────────────────────────────────────────┐
│                    LLMGateway.chat()                  │
│                                                      │
│  1. Build LLMRequest                                │
│  2. ┌─ Cache Check ─────────────────────────────┐   │
│     │  generate_cache_key(req)                    │   │
│     │  cache.get(key) ──→ CacheResult             │   │
│     │    ├─ HIT (exact)  → return cached response │   │
│     │    └─ MISS → semantic_search(query_emb)     │   │
│     │         ├─ HIT (semantic) → return response │   │
│     │         └─ MISS → call provider              │   │
│     └─────────────────────────────────────────────┘   │
│  3. Call provider → LLMResponse                      │
│  4. cache.put(key, response, query_embedding)         │
│  5. Record usage                                     │
└──────────────────────────────────────────────────────┘
```

### File Structure

```
src/agentkit/llm/
├── cache.py          # NEW: LLMCache Protocol + InMemoryLLMCache + RedisLLMCache + CacheResult
├── cache_key.py      # NEW: generate_cache_key(), hash helpers
├── gateway.py        # MODIFIED in U2: inject cache check
├── config.py         # MODIFIED in U2: add CacheConfig
└── ...
```

---

## 3. Data Model Design

### 3.1 CacheKey

**Reasoning**: The cache key must capture ALL inputs that deterministically affect LLM output. Missing any component leads to false cache hits (wrong response returned).

| Component | Why Included | Hash Method |
|-----------|-------------|-------------|
| `model` | Different models produce different outputs | UTF-8 encode → SHA-256 |
| `system_prompt` | Changes behavior fundamentally | SHA-256 of full text |
| `messages` | Core conversation context | SHA-256 of JSON-serialized messages |
| `temperature` | Affects randomness; only 0.0 is deterministic | Float string representation |
| `tools` | Available tools affect tool_call generation | SHA-256 of JSON-serialized tools list |
| `tool_choice` | "auto" vs "none" changes behavior | UTF-8 encode → SHA-256 |

**Key formula**:
```python
key = SHA256(
    SHA256(model) +
    SHA256(system_prompt) +
    SHA256(json(messages, sort_keys=True)) +
    SHA256(str(temperature)) +
    SHA256(json(tools, sort_keys=True)) +
    SHA256(tool_choice)
)
```

**Design Decision — Why not include `max_tokens`?**
`max_tokens` is a truncation limit, not a semantic input. A response cached with `max_tokens=2000` is still valid when requested with `max_tokens=4000` (the response was simply shorter). However, the reverse is unsafe — a response generated with `max_tokens=4000` might be longer than a `max_tokens=2000` request expects. **Decision**: Include `max_tokens` in the key to be safe. The cost of a few extra cache misses is negligible compared to returning a response that violates the caller's token limit.

**Revised key formula**:
```python
key = SHA256(
    SHA256(model) +
    SHA256(system_prompt) +
    SHA256(json(messages, sort_keys=True)) +
    SHA256(f"{temperature:.2f}") +
    SHA256(json(tools, sort_keys=True)) +
    SHA256(tool_choice) +
    SHA256(str(max_tokens))
)
```

### 3.2 CacheEntry

```python
@dataclass
class CacheEntry:
    """A cached LLM response with metadata."""
    response: LLMResponse          # The cached response
    query_embedding: list[float]   # Embedding of last user message (for semantic match)
    created_at: float              # time.monotonic() when cached
    hit_count: int                 # Number of cache hits
```

### 3.3 CacheResult

```python
@dataclass
class CacheResult:
    """Result of a cache lookup."""
    hit: bool                      # Whether a cache hit occurred
    response: LLMResponse | None   # The cached response (None on miss)
    match_type: str                # "exact" | "semantic" | "" (miss)
```

---

## 4. Protocol Design

### 4.1 LLMCache Protocol

```python
class LLMCache(Protocol):
    """LLM response cache interface."""

    async def get(self, key: str) -> CacheResult:
        """Look up a cached response by exact key, then semantic search."""
        ...

    async def put(self, key: str, response: LLMResponse, query_embedding: list[float] | None = None) -> None:
        """Store a response in the cache."""
        ...

    async def invalidate(self, pattern: str | None = None) -> int:
        """Invalidate cache entries. If pattern is None, invalidate all. Returns count of invalidated entries."""
        ...

    async def stats(self) -> dict[str, int]:
        """Return cache statistics: {total_entries, total_hits, total_misses}."""
        ...
```

**Reasoning for async Protocol**: All methods are async because `RedisLLMCache` uses `redis.asyncio`. Making the Protocol async ensures both backends share the same interface without sync/async bridging.

**Why `get()` does both exact + semantic?** The caller (LLMGateway) shouldn't need to know about the two-tier lookup. It calls `cache.get(key)` and gets a `CacheResult` with `match_type` indicating how the hit occurred. This encapsulation keeps the integration point simple.

### 4.2 Semantic Search Design

**Critical Question**: Should semantic search be inside `get()` or a separate method?

**Analysis**:
- **Option A**: `get(key)` does exact match first, then semantic search on miss. Single call, simple integration.
- **Option B**: Separate `semantic_search(embedding)` method. More flexible, but requires caller to manage two calls.

**Decision**: Option A. The semantic search needs the `query_embedding`, which must be computed before calling `get()`. But embedding computation is expensive (~100ms). We don't want to compute embeddings on every cache miss — only when semantic caching is enabled and temperature == 0.

**Revised design**:

```python
class LLMCache(Protocol):
    async def get(self, key: str) -> CacheResult:
        """Exact-match lookup only."""
        ...

    async def semantic_search(self, query_embedding: list[float], threshold: float = 0.92) -> CacheResult:
        """Semantic similarity search across all cached entries."""
        ...

    async def put(self, key: str, response: LLMResponse, query_embedding: list[float] | None = None) -> None:
        """Store response with optional embedding for semantic matching."""
        ...
```

**Integration flow in LLMGateway (U2)**:
```python
# 1. Exact match
result = await cache.get(key)
if result.hit:
    return result.response

# 2. Semantic match (only for temperature == 0)
if request.temperature == 0 and query_embedding is not None:
    result = await cache.semantic_search(query_embedding)
    if result.hit:
        return result.response

# 3. Call provider
response = await provider.chat(request)
await cache.put(key, response, query_embedding)
```

This gives the gateway explicit control over when to attempt semantic search, avoiding unnecessary embedding computation.

---

## 5. InMemoryLLMCache Implementation Design

### 5.1 Data Structure

```python
class InMemoryLLMCache:
    def __init__(self, max_entries: int = 10000, exact_ttl: int = 3600, semantic_ttl: int = 86400, similarity_threshold: float = 0.92):
        self._max_entries = max_entries
        self._exact_ttl = exact_ttl
        self._semantic_ttl = semantic_ttl
        self._similarity_threshold = similarity_threshold

        # Exact cache: key → CacheEntry
        self._cache: OrderedDict[str, CacheEntry] = OrderedDict()

        # Semantic index: key → query_embedding (parallel to _cache)
        self._embeddings: dict[str, list[float]] = {}

        # Stats
        self._hits = 0
        self._misses = 0
```

### 5.2 Key Operations

**`get(key)`**:
1. Look up `key` in `_cache`
2. If found and not expired (check `created_at + exact_ttl > now`): increment `hit_count`, move to end (LRU), return `CacheResult(hit=True, match_type="exact")`
3. If expired: delete from `_cache` and `_embeddings`
4. Return `CacheResult(hit=False)`

**`semantic_search(query_embedding, threshold)`**:
1. If `_embeddings` is empty: return miss
2. For each `(key, emb)` in `_embeddings`:
   a. Check if entry is still valid (`created_at + semantic_ttl > now`)
   b. If expired: skip (lazy cleanup)
   c. Compute `cosine_similarity(query_embedding, emb)`
   d. Track best match
3. If best similarity >= threshold: return `CacheResult(hit=True, match_type="semantic")`
4. Return miss

**Performance**: O(n) scan over all embeddings. With <10000 entries and 1536-dim vectors, this takes <10ms using numpy. Acceptable for now. If scale becomes an issue, switch to FAISS or pgvector (deferred).

**`put(key, response, query_embedding)`**:
1. Create `CacheEntry(response, query_embedding or [], now, 0)`
2. If key exists: update, move to end
3. If new and at capacity: evict LRU (popitem(last=False))
4. Store embedding in `_embeddings[key]` if provided

**`invalidate(pattern)`**:
1. If pattern is None: clear all
2. If pattern: iterate keys, match against pattern, delete matching entries

### 5.3 LRU Eviction Strategy

Follow `EmbeddingCache` pattern: `OrderedDict` with `move_to_end()` on access, `popitem(last=False)` on eviction. This is O(1) for both access and eviction.

**Why not size-based eviction?** LLM responses vary widely in size (100 bytes to 10KB). Entry-count-based eviction is simpler and more predictable. With `max_entries=10000` and average response ~1KB, memory usage is ~10MB — acceptable.

---

## 6. RedisLLMCache Implementation Design

### 6.1 Key Schema

```
agentkit:llm_cache:{sha256_hex}          → JSON(CacheEntry) with TTL
agentkit:llm_cache_emb:{sha256_hex}      → JSON(list[float]) with TTL
```

**Why two keys instead of one?**
- Semantic search needs to iterate all embeddings without downloading full response bodies
- Embedding keys are small (~12KB for 1536-dim float list) vs. response keys (variable, potentially large with tool_calls)
- Different TTLs: exact cache may have shorter TTL than semantic cache

**Alternative considered**: Single key with embedded embedding. Rejected because `KEYS agentkit:llm_cache:*` + `GET` for each key to extract embedding would download all response bodies for semantic search, which is wasteful.

### 6.2 Key Operations

**`get(key)`**:
1. `GET agentkit:llm_cache:{key}` → deserialize CacheEntry
2. If found: `INCR agentkit:llm_cache_hits:{key}` (optional, for stats), return hit
3. Return miss

**`semantic_search(query_embedding, threshold)`**:
1. `KEYS agentkit:llm_cache_emb:*` → get all embedding keys
2. `MGET` all embedding keys → deserialize embeddings
3. Compute cosine similarity for each
4. If best >= threshold: `GET agentkit:llm_cache:{best_key}` → return hit
5. Return miss

**Performance concern**: `KEYS` is O(N) and blocks Redis. For production with >1000 cached entries, this is unacceptable.

**Mitigation**: Use `SCAN` instead of `KEYS` for iteration. Store a Redis Set `agentkit:llm_cache_index` containing all active cache keys. On `put()`, `SADD agentkit:llm_cache_index {key}`. On `invalidate()`, `SREM`. For semantic search, `SMEMBERS agentkit:llm_cache_index` → `MGET` embeddings.

**Revised key schema**:
```
agentkit:llm_cache:{sha256_hex}          → JSON(CacheEntry) with TTL
agentkit:llm_cache_emb:{sha256_hex}      → JSON(list[float]) with TTL
agentkit:llm_cache_index                 → SET of active cache keys (no TTL, managed manually)
```

**`put(key, response, query_embedding)`**:
1. Pipeline: `SET agentkit:llm_cache:{key} → JSON(CacheEntry) EX exact_ttl`
2. If embedding provided: `SET agentkit:llm_cache_emb:{key} → JSON(embedding) EX semantic_ttl`
3. `SADD agentkit:llm_cache_index {key}`

**`invalidate(pattern)`**:
1. If pattern is None: `SMEMBERS agentkit:llm_cache_index` → pipeline DEL all keys → DEL index
2. If pattern: `SMEMBERS` → filter by pattern → pipeline DEL matching keys → SREM from index

### 6.3 Lazy Redis Initialization

Follow `RedisSessionStore._get_redis()` pattern:

```python
class RedisLLMCache:
    def __init__(self, redis_url: str = "redis://localhost:6379", ...):
        self._redis_url = redis_url
        self._redis: aioredis.Redis | None = None

    async def _get_redis(self) -> aioredis.Redis:
        if self._redis is None:
            import redis.asyncio as aioredis
            self._redis = aioredis.from_url(self._redis_url, decode_responses=True)
        return self._redis
```

### 6.4 Connection Error Handling

```python
async def get(self, key: str) -> CacheResult:
    try:
        redis = await self._get_redis()
        data = await redis.get(f"agentkit:llm_cache:{key}")
        ...
    except (redis.ConnectionError, redis.TimeoutError) as e:
        logger.warning(f"Redis cache unavailable, returning miss: {e}")
        return CacheResult(hit=False)
```

**Design Decision**: On Redis failure, return cache miss (not error). The cache is a performance optimization, not a correctness requirement. Failing open is the correct behavior.

---

## 7. Factory Function

```python
def create_llm_cache(
    backend: str = "auto",
    redis_url: str = "redis://localhost:6379",
    max_entries: int = 10000,
    exact_ttl: int = 3600,
    semantic_ttl: int = 86400,
    similarity_threshold: float = 0.92,
) -> LLMCache:
    """Create an LLM cache backend.

    Args:
        backend: "auto" (try Redis, fallback to memory), "redis", "memory"
        ...
    """
    if backend in ("auto", "redis"):
        try:
            import redis.asyncio as aioredis
            return RedisLLMCache(redis_url=redis_url, ...)
        except ImportError:
            logger.warning("redis package not available, falling back to in-memory cache")
            return InMemoryLLMCache(...)
    return InMemoryLLMCache(...)
```

**Follows existing pattern**: `create_session_store()`, `create_evolution_store()`.

---

## 8. CacheKey Generation Design

### 8.1 Module: `cache_key.py`

```python
import hashlib
import json

def generate_cache_key(
    model: str,
    messages: list[dict[str, str]],
    temperature: float,
    tools: list[dict] | None = None,
    tool_choice: str = "auto",
    max_tokens: int = 2000,
    system_prompt: str | None = None,
) -> str:
    """Generate a deterministic SHA-256 cache key from LLM request parameters."""
    components = [
        _hash_str(model),
        _hash_str(system_prompt or _extract_system_prompt(messages)),
        _hash_json(messages),
        _hash_str(f"{temperature:.2f}"),
        _hash_json(tools),
        _hash_str(tool_choice),
        _hash_str(str(max_tokens)),
    ]
    combined = "".join(components)
    return hashlib.sha256(combined.encode()).hexdigest()

def _extract_system_prompt(messages: list[dict]) -> str:
    """Extract system prompt from messages list."""
    for msg in messages:
        if msg.get("role") == "system":
            return msg.get("content", "")
    return ""

def _hash_str(s: str) -> str:
    return hashlib.sha256(s.encode()).hexdigest()

def _hash_json(obj) -> str:
    if obj is None:
        return hashlib.sha256(b"null").hexdigest()
    return hashlib.sha256(json.dumps(obj, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
```

### 8.2 Why Separate `system_prompt` Parameter?

The `messages` list already contains the system prompt. But in AgentKit, the system prompt is injected separately from the user's messages (via `MemoryStore.build_system_prompt()`). The gateway receives `messages` that already include the system prompt. So `system_prompt` is extracted from `messages[0]` when `role == "system"`.

**No separate parameter needed** — `_extract_system_prompt()` handles extraction. This avoids requiring callers to pass system_prompt separately.

---

## 9. Semantic Match: Temperature Gate

**Rule**: Semantic matching is ONLY attempted when `temperature == 0.0`.

**Reasoning**:
- At `temperature > 0`, LLM outputs are non-deterministic. Two semantically similar requests may produce different outputs.
- Caching a `temperature=0.7` response and returning it for a semantically similar query is misleading — the user expects randomness.
- At `temperature=0.0`, outputs are deterministic (within provider guarantees), so semantic matching is safe.

**Implementation**: The gateway checks `temperature` before calling `semantic_search()`. The cache itself does not enforce this — it's a policy decision made by the caller.

---

## 10. Serialization Design

### 10.1 LLMResponse Serialization

`LLMResponse` contains `content: str`, `model: str`, `usage: TokenUsage`, `tool_calls: list[ToolCall]`.

**For InMemoryLLMCache**: No serialization needed — store Python objects directly.

**For RedisLLMCache**: Serialize to JSON.

```python
def _serialize_response(response: LLMResponse) -> dict:
    return {
        "content": response.content,
        "model": response.model,
        "usage": {
            "prompt_tokens": response.usage.prompt_tokens,
            "completion_tokens": response.usage.completion_tokens,
        },
        "tool_calls": [
            {"id": tc.id, "name": tc.name, "arguments": tc.arguments}
            for tc in response.tool_calls
        ],
        "latency_ms": response.latency_ms,
    }

def _deserialize_response(data: dict) -> LLMResponse:
    return LLMResponse(
        content=data["content"],
        model=data["model"],
        usage=TokenUsage(**data["usage"]),
        tool_calls=[ToolCall(**tc) for tc in data.get("tool_calls", [])],
        latency_ms=data.get("latency_ms", 0.0),
    )
```

### 10.2 Embedding Serialization

Embeddings are `list[float]` with 1536 dimensions. JSON serialization produces ~12KB per embedding.

**Alternative**: Binary serialization (struct.pack) would reduce to ~6KB but adds complexity. JSON is sufficient for now.

---

## 11. Edge Cases & Failure Modes

| Edge Case | Behavior | Rationale |
|-----------|----------|-----------|
| Response with `tool_calls` | Cached normally | Tool call responses are deterministic at temperature=0 |
| Empty response (`content=""`) | Cached normally | Empty responses are valid (e.g., tool-only responses) |
| Very large response (>100KB) | Cached, but counted as single entry | Size-based eviction deferred; entry-count is sufficient |
| Concurrent `put()` for same key | Last write wins | No data corruption risk; both writes are valid responses |
| Redis `SET` fails | Log warning, cache miss on next read | Fail open, never block LLM calls |
| Embedding API fails during `put()` | Store response without embedding | Exact-match still works; semantic match degraded |
| Embedding API fails during `semantic_search()` | Return cache miss | Don't block on embedding failures |
| `invalidate()` while `get()` in progress | Possible stale read | Acceptable for cache; eventual consistency |

---

## 12. Test Strategy

### 12.1 Unit Tests (`tests/unit/llm/test_cache.py`)

Using `pytest` + `pytest-asyncio`:

1. **test_exact_match_hit**: Same key → cache hit, `match_type="exact"`
2. **test_exact_match_miss**: Different key → cache miss
3. **test_semantic_match_hit**: Paraphrased query with similarity > 0.92 → hit, `match_type="semantic"`
4. **test_semantic_match_miss**: Unrelated query with similarity < 0.6 → miss
5. **test_semantic_match_boundary**: Similarity exactly at threshold → hit
6. **test_ttl_expiry_exact**: Entry expires after exact_ttl → miss
7. **test_ttl_expiry_semantic**: Entry expires after semantic_ttl → miss
8. **test_lru_eviction**: Add max_entries + 1 → oldest evicted
9. **test_invalidate_all**: `invalidate()` clears all entries
10. **test_invalidate_pattern**: `invalidate("prefix:*")` clears matching entries
11. **test_cache_stats**: `stats()` returns correct counts
12. **test_tool_calls_cached**: Response with tool_calls cached and restored correctly
13. **test_concurrent_puts**: Two concurrent puts for same key → no error
14. **test_redis_fallback**: Redis import fails → InMemoryLLMCache returned
15. **test_cache_key_deterministic**: Same inputs → same key
16. **test_cache_key_different_model**: Different model → different key
17. **test_cache_key_different_temperature**: Different temperature → different key

### 12.2 Mock Embedder for Testing

Use `MockEmbedder` from `src/agentkit/memory/embedder.py`. Since `MockEmbedder` generates deterministic embeddings based on text hash, semantically similar text will produce similar embeddings (same hash prefix → similar vector). This is sufficient for testing the similarity threshold logic.

**Limitation**: `MockEmbedder` doesn't produce truly semantically meaningful embeddings. For testing semantic matching behavior, we'll manually construct embeddings with known cosine similarities.

```python
def _make_embedding(base: list[float], noise: float = 0.0) -> list[float]:
    """Create a unit vector with optional noise for similarity testing."""
    vec = [x + noise for x in base]
    magnitude = sum(x**2 for x in vec) ** 0.5
    return [x / magnitude for x in vec] if magnitude > 0 else vec
```

---

## 13. Dependency Analysis

### 13.1 Internal Dependencies

| Dependency | Usage | Risk |
|-----------|-------|------|
| `agentkit.llm.protocol.LLMResponse` | Cache entry data type | Stable, no change needed |
| `agentkit.llm.protocol.TokenUsage` | Part of LLMResponse | Stable |
| `agentkit.llm.protocol.ToolCall` | Part of LLMResponse | Stable |
| `agentkit.memory.embedder.Embedder` | Embedding computation for semantic match | Injected, not imported directly |
| `agentkit.utils.vector_math.compute_cosine_similarity` | Similarity computation | Stable utility |

### 13.2 External Dependencies

| Dependency | Usage | Required? |
|-----------|-------|-----------|
| `redis.asyncio` | RedisLLMCache backend | Optional (only for "redis" backend) |
| `numpy` | Fast cosine similarity | Optional (pure-python fallback exists) |

---

## 14. Implementation Sequence

Within U1, the implementation order is:

1. **`cache_key.py`** — No dependencies, pure functions, easy to test
2. **`cache.py`** — `CacheResult`, `CacheEntry`, `LLMCache` Protocol, `InMemoryLLMCache`
3. **`cache.py`** — `RedisLLMCache`, `create_llm_cache()` factory
4. **`test_cache.py`** — All unit tests

This order allows incremental testing: cache_key tests first, then InMemoryLLMCache tests, then RedisLLMCache tests.

---

## 15. Open Design Questions

1. **Should `semantic_search()` return the best match or all matches above threshold?**
   - **Current decision**: Best match only. The gateway needs one response, not a ranked list. If we need ranked results later, we can add a `search()` method.

2. **Should the cache store the original `messages` alongside the response?**
   - **Current decision**: No. The key already deterministically represents the messages. Storing them again wastes memory. If we need message-level debugging, we can add it later.

3. **Should `RedisLLMCache` use Redis Hash instead of individual keys?**
   - **Current decision**: Individual keys with SET index. Hash would allow `HGETALL` for all entries, but makes TTL per-entry impossible (Redis Hash fields don't support individual TTLs). Individual keys with a SET index is the standard pattern.

4. **What embedding model to use for semantic cache?**
   - **Decision**: Default to `bge-m3` (BAAI/bge-m3 via Xinference or TEI endpoint) for Chinese+English mixed text. `bge-m3` supports:
     - Multi-lingual (102 languages, strong Chinese)
     - Multi-granularity (dense + sparse + ColBERT)
     - Multi-function (retrieval + classification + similarity)
     - 1024-dim dense vectors (vs. 1536 for OpenAI)
   - Fallback to `text-embedding-3-small` when only OpenAI API is available.
   - The embedder is injected via constructor, so the model choice is a configuration concern, not a code concern.
   - **Config example**:
     ```yaml
     llm:
       cache:
         embedding:
           provider: "xinference"    # "xinference" | "openai" | "local"
           model: "bge-m3"           # model name at provider
           base_url: "http://localhost:9997/v1"
     ```

---

## 16. Argumentation Summary

| Design Choice | Alternatives Considered | Why This Choice |
|--------------|------------------------|----------------|
| SHA-256 hash key | UUID, MD5, composite string key | SHA-256 is collision-resistant, deterministic, fixed-length; MD5 has known collisions; UUID is non-deterministic |
| OrderedDict LRU | heapq, custom doubly-linked-list | OrderedDict is Python-idiomatic, O(1) access+eviction, matches EmbeddingCache pattern |
| Separate `get()` + `semantic_search()` | Combined `get()` with auto-semantic | Explicit control avoids unnecessary embedding computation; caller decides when to attempt semantic match |
| Redis SET index for semantic search | KEYS pattern scan, Redis Hash | KEYS blocks Redis; Hash doesn't support per-field TTL; SET index is standard pattern |
| Fail-open on Redis error | Raise exception, return None | Cache is optimization, not correctness; failing open ensures LLM calls always work |
| Temperature gate for semantic match | Always attempt semantic match | temperature>0 outputs are non-deterministic; semantic match would return misleading cached responses |
| JSON serialization for Redis | MessagePack, Pickle, Protobuf | JSON is human-readable, debuggable, no extra dependencies; sufficient for <10KB entries |
| bge-m3 default embedding | text-embedding-3-small, multilingual-e5 | bge-m3 is SOTA for Chinese+English mixed text; 1024-dim saves 33% memory vs OpenAI 1536-dim; OpenAI-compatible API via Xinference/TEI |
