diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 941e7e0..033ff64 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -244,6 +244,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ ref: ${{ github.ref }}
- uses: actions/download-artifact@v4
with:
path: artifacts
@@ -261,15 +265,37 @@ jobs:
mv artifacts/conproxy-aarch64-unknown-linux-gnu/conproxy \
artifacts/conproxy-aarch64-unknown-linux-gnu/conproxy-aarch64-unknown-linux-gnu
chmod +x artifacts/conproxy-*/conproxy-*
+ - name: Resolve previous tag for compare link
+ id: prev
+ run: |
+ # Find the most recent semver tag (excluding the current one).
+ PREV=$(git tag --list 'v*' --sort=-v:refname \
+ | grep -v "^${GITHUB_REF_NAME}$" \
+ | head -n 1 || true)
+ if [ -n "$PREV" ]; then
+ echo "PREV=$PREV" >> "$GITHUB_OUTPUT"
+ echo "COMPARE=https://github.com/${{ github.repository }}/compare/${PREV}...${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
+ else
+ echo "PREV=" >> "$GITHUB_OUTPUT"
+ echo "COMPARE=" >> "$GITHUB_OUTPUT"
+ fi
- name: Create release + attach assets
uses: softprops/action-gh-release@v2
with:
name: ${{ github.ref_name }}
generate_release_notes: true
+ prerelease: ${{ contains(github.ref_name, '-') }}
+ make_latest: ${{ !contains(github.ref_name, '-') }}
body: |
## conproxy ${{ steps.v.outputs.VERSION }}
- Install:
+ ${{ steps.prev.outputs.COMPARE && format('**Compare:** [{0}…{1}]({2})', steps.prev.outputs.PREV, github.ref_name, steps.prev.outputs.COMPARE) || '**First release.**' }}
+
+ **Stability**
+ - **Shipped:** exact cache, MCP suite, all backends except Pinecone / Milvus
+ - **Experimental:** Pinecone, Milvus, peer mesh (no mTLS — see README)
+
+ **Install**
```bash
# cargo (binary)
@@ -283,6 +309,8 @@ jobs:
helm install conproxy ${{ env.HELM_OCI }}/conproxy \
--version ${{ steps.v.outputs.VERSION }}
```
+
+ **Docs:** [README](https://github.com/${{ github.repository }}#readme) · [Benchmarks](https://github.com/${{ github.repository }}/blob/${{ github.ref_name }}/docs/benchmarks.md)
files: |
artifacts/conproxy-x86_64-unknown-linux-musl/conproxy-x86_64-unknown-linux-musl
artifacts/conproxy-aarch64-unknown-linux-gnu/conproxy-aarch64-unknown-linux-gnu
diff --git a/AGENTS.md b/AGENTS.md
index dc83fbb..5133ced 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,6 +2,15 @@
Cache proxy for heterogeneous RAG/vector search backends (Elasticsearch, OpenSearch, Qdrant, pgvector, Meilisearch, Pinecone, Milvus). Rust 2021, Axum + tonic, multi-process: lib + `conproxy` CLI + `test_runner` + `generate_embeddings` + `perf_summarize` + `hitrate_bench` + `console_snap` + `corpus_seed` + Python SDK.
+## Product framing (for PR copy, README, and any external docs you write)
+
+- **What:** retrieval-leg cache for **agentic RAG** (embed + upstream search). **Not** an LLM-answer cache.
+- **Pitch:** cost + faster search on **hits**; agents re-query (retries, fanout, tool storms).
+- **Not:** GPTCache / RedisVL SemanticCache territory; not "faster chat RAG" as the headline alone.
+- **Proof:** `docs/benchmarks.md` + `make bench-hitrate*`; BYO with `make bench-hitrate-replay QUERIES=…`.
+- **User-facing decision docs:** `README.md` (consider/skip + vs table), `docs/benchmarks.md`. This file is ops-focused.
+- **Stability:** pinecone / milvus experimental; peer = trusted network, no mTLS.
+
## Fast Feedback Tiers
Three tiers — Tier 1 & 2 for the per-PR loop, Tier 3 for release publishing. Run the tier that matches your stage. Vertical-specific commands live in the `contributing` skill (Feature Test Matrix + 14 verticals).
diff --git a/README.md b/README.md
index 0de8397..3648650 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,13 @@
# conproxy
-> Search retrieval cache for agentic RAG.
+> Retrieval cache for agentic RAG — lower cost, faster search.
-conproxy is a caching proxy for search backends. LLM caches skip re-generating answers. conproxy skips re-running the search — embed, rerank, backend — when agents ask the same thing twice.
+conproxy sits in front of your search backends. LLM caches skip re-generating answers. conproxy skips re-running the search — embed, rerank, upstream — when agents hit the same (or near-same) query again.
+
+**Why it pays**
+
+- **Cost** — hits skip another embed call and managed-vector read
+- **Speed** — cache hits ~**138×** faster than miss path on the agentic live bench (hit p50 ~0.1 ms vs miss ~13.8 ms; ~89.5% exact hit rate) — [benchmarks](docs/benchmarks.md)
**When to use**
@@ -16,7 +21,53 @@ conproxy is a caching proxy for search backends. LLM caches skip re-generating a
- LLM-response caching (that's GPTCache or RedisVL SemanticCache territory)
- Cross-org mTLS peer replication (not planned; use a mesh sidecar)
-One MCP endpoint, any backend, semantic tier with false-hit gating. Benchmarks reproducible.
+**The problem**
+
+LLM caches (GPTCache, RedisVL SemanticCache) skip re-generating answers, but agents still re-rerank, re-embed, and re-query the same corpora on retries, multi-agent fanout, and tool-call storms. Every repeated retrieval costs an embed call and a managed-vector read. conproxy caches the retrieval leg itself.
+
+**Consider conproxy if…**
+
+- [ ] Multiple agents or tool loops hit the same corpus
+- [ ] Embed or managed-vector $ is visible
+- [ ] You want one MCP/HTTP search façade over ES / Qdrant / pgvector / Meilisearch / Pinecone / Milvus
+- [ ] You need measured hit rate / false-hit gate, not vibes (`make bench-hitrate`)
+
+**Skip conproxy if…**
+
+- You only need an LLM-response cache → use GPTCache / RedisVL
+- A single in-process memoize hash covers your duplicates
+- You need write-path CDC / multi-region invalidation today (not shipped; track correctness doc)
+- One tiny backend, no agent loops, no cost pressure
+
+**vs alternatives**
+
+| Need | Prefer |
+|------|--------|
+| Cache **LLM answers** | GPTCache / RedisVL SemanticCache |
+| Cache **search/retrieval** under agents | **conproxy** |
+| One process, no daemon, single backend | In-process memoize / app cache |
+| Multi-backend cascade / MCP tune / dry-run scope | **conproxy** |
+| LLM-side semantic cache for prompts | LangChain cache / provider-level caching |
+
+**At a glance**
+
+| | |
+|--|--|
+| **Category** | Retrieval-leg cache for agentic RAG |
+| **Not** | LLM answer cache (GPTCache / RedisVL) |
+| **Pays when** | Agents re-query — hits skip embed + upstream |
+| **Proof** | ~89.5% exact hit rate; hit p50 ~0.1 ms vs miss ~13.8 ms (~**138×**) — [benchmarks](docs/benchmarks.md) |
+| **Integrate** | MCP `conproxy mcp` · HTTP/gRPC · [Python SDK](docs/sdk-python.md) |
+
+**FAQ**
+
+- **What is conproxy?** A caching proxy in front of search backends. Caches retrieval results, not LLM tokens.
+- **How is it different from GPTCache / RedisVL SemanticCache?** Those cache LLM answers. conproxy caches embed + search results for agents re-querying the same corpora.
+- **When does it pay?** Retries, multi-agent fanout, tool-call storms. Cost + latency win on every hit.
+- **How do I try it?** Install (binary / Docker / Helm) → see Quick Start below. One curl hits the proxy.
+- **How do I prove it on my data?** `make bench-hitrate` for synthetic traces; `make bench-hitrate-replay QUERIES=path/to/trace.txt` for your real query log.
+
+One MCP endpoint, any backend, cost + latency on hits, false-hit gated semantic tier. Benchmarks reproducible.
```
agent ──► MCP / HTTP / gRPC ──► conproxy ──► backends
@@ -36,6 +87,7 @@ Works with Elasticsearch, OpenSearch, Qdrant, pgvector, Meilisearch, Pinecone, M
**Agentic cache**
- In-memory cache with TTL, jitter, and background refresh; S3-FIFO eviction
+- Hit path skips embed + upstream — **cost and latency** win on every hit; coalesce collapses concurrent duplicates
- Semantic tier with τ-frontier and measured false-hit rate (≤1% gate)
- Request coalescing (singleflight) to collapse concurrent duplicates
- Negative caching for errors; serve-stale-while-refresh
@@ -112,6 +164,15 @@ docker run -d --name conproxy -p 9999:9999 -p 10000:10000 \
ghcr.io/jmcgrath207/conproxy:0.1.0
```
+**Docker Compose (proxy + Meilisearch):**
+```bash
+git clone https://github.com/jmcgrath207/conproxy
+cd conproxy/examples/docker-compose
+docker compose up -d
+curl -s http://127.0.0.1:10000/health
+```
+See [`examples/docker-compose/`](examples/docker-compose/) and [`docs/docker-compose.md`](docs/docker-compose.md) for the full walkthrough.
+
**Helm (Kubernetes):**
```bash
helm install conproxy oci://ghcr.io/jmcgrath207/charts/conproxy \
@@ -259,6 +320,7 @@ Multi-leg cascade and federated variants: see [`examples/multi-upstream-cascade.
| [MCP Integration](docs/mcp-integration.md) | Setup for Claude Desktop, opencode, and other stdio clients; tune tools |
| [Distill](docs/distill.md) | Cache export for LLM ingestion |
| [Deployment](docs/deployment.md) | Production setup and monitoring |
+| [Docker Compose](docs/docker-compose.md) | Side-by-side conproxy + backend stack ([example](examples/docker-compose/)) |
| [Feature Flags](docs/feature-flags.md) | Compile-time features |
| [Python SDK](docs/sdk-python.md) | Native client + LangChain/LlamaIndex adapters |
diff --git a/docs/deployment.md b/docs/deployment.md
index b92ba5f..fef236e 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -107,16 +107,38 @@ services:
ports:
- "9999:9999"
- "10000:10000"
- volumes:
- - ./.conproxy:/var/lib/conproxy/.conproxy:ro
- command: start --listen 0.0.0.0:9999
+ ### Docker Compose
+
+For a runnable, side-by-side conproxy + Meilisearch stack (pinned versions,
+healthcheck, non-root) see
+[`docs/docker-compose.md`](docker-compose.md) and
+[`examples/docker-compose/`](../examples/docker-compose/).
+
+```yaml
+# Minimal shape (full file in the example):
+services:
+ meilisearch:
+ image: getmeili/meilisearch:v1.8
+ environment:
+ MEILI_NO_ANALYTICS: "true"
+ ports: ["7700:7700"]
- qdrant:
- image: qdrant/qdrant:latest
+ conproxy:
+ image: ghcr.io/jmcgrath207/conproxy:0.1.0
+ depends_on:
+ meilisearch: { condition: service_healthy }
ports:
- - "6333:6333"
+ - "9999:9999"
+ - "10000:10000"
+ volumes:
+ - ./conproxy.toml:/etc/conproxy/conproxy.toml:ro
+ command: ["start", "--config", "/etc/conproxy/conproxy.toml", "--listen", "0.0.0.0:9999"]
```
+Note: the in-repo `tests/e2e/docker-compose.yml` boots the **full test
+matrix** (qdrant + ES + OS + meilisearch×2 + pgvector) and is **not** a
+user-facing starting point — use `examples/docker-compose/` instead.
+
## P2P Replication
Multiple conproxy instances can replicate cache state between each other using CDC events over gRPC.
diff --git a/docs/docker-compose.md b/docs/docker-compose.md
new file mode 100644
index 0000000..efedc70
--- /dev/null
+++ b/docs/docker-compose.md
@@ -0,0 +1,136 @@
+# Docker Compose
+
+Quickest way to bring up conproxy + a backend on a single host. Run from
+`examples/docker-compose/`:
+
+```bash
+docker compose up -d
+curl -s http://127.0.0.1:10000/health
+```
+
+For the full step-by-step (customize, seed, troubleshoot), see
+[`examples/docker-compose/README.md`](../examples/docker-compose/README.md).
+This page covers the why behind the compose layout and the production gaps.
+
+## Layout
+
+```mermaid
+flowchart LR
+ Host["host: docker compose"] -->|port 9999 gRPC| Conproxy["conproxy:0.1.0
+ conproxy.toml"]
+ Host -->|port 10000 HTTP| Conproxy
+ Conproxy -->|"http://meilisearch:7700"| Meili[("Meilisearch
v1.8")]
+ Meili <-->|volume| M[(meili_data)]
+```
+
+Two services, one user-defined volume for Meilisearch. conproxy is stateless
+and runs as the non-root `conproxy` user (uid 10001).
+
+Meilisearch is chosen for the example because it's **text-native out of the
+box** — no FastEmbed or local embedder configuration needed for the demo
+flow. Swap it for Qdrant / Elasticsearch / pgvector (see
+`examples/multi-upstream-cascade.toml`) when you want vector search.
+
+## Service design choices
+
+| Choice | Reason |
+|--------|--------|
+| Pinned image tags (`0.1.0`, `meilisearch v1.8`) | Reproducibility — `:latest` drifts |
+| Meilisearch healthcheck + `depends_on: service_healthy` | Avoids race on first boot |
+| `no-new-privileges` on conproxy | Cheap hardening, blocks trivial escalation |
+| `restart: unless-stopped` on conproxy | Default for a long-running daemon |
+| HTTP listen `0.0.0.0` (via CLI or `[server]`) | Required for Docker port-mapping |
+| Upstream URL uses **Compose DNS name**, not `localhost` | Cross-service networking |
+| Single user-defined volume (Meilisearch only) | conproxy is in-memory by default; add a volume if you enable `persistence` |
+
+## Ports
+
+| Port | Service | Used for |
+|------|---------|----------|
+| `9999` | conproxy gRPC | Programmatic query + admin |
+| `10000` | conproxy HTTP | `/query`, `/health`, `/metrics`, `/cache/*`, `/admin/*` |
+| `7700` | Meilisearch HTTP | (host-mapped for direct seeding via meili CLI / curl) |
+
+## Customizing the image
+
+For local builds (e.g. CI smoke of a PR), replace the image with a `build:`
+key pointing at the repo root or your fork:
+
+```yaml
+services:
+ conproxy:
+ build:
+ context: ../..
+ dockerfile: Dockerfile
+ # ...rest unchanged
+```
+
+This honors the same `release` feature flags baked into the published image
+(`mcp` + `persistence` + `embed-api` + `pgvector`).
+
+## Adding more services
+
+Add `meilisearch`, `elasticsearch`, or `pgvector` blocks to the same file
+and reference them by service name in `conproxy.toml`:
+
+```yaml
+ meilisearch:
+ image: getmeili/meilisearch:v1.8
+ environment:
+ MEILI_NO_ANALYTICS: "true"
+ ports: ["7700:7700"]
+```
+
+```toml
+[upstreams.meili]
+url = "http://meilisearch:7700"
+type = "meilisearch"
+
+[[contexts.default.upstreams]]
+ref = "meili"
+```
+
+For a multi-leg cascade, see
+[`examples/multi-upstream-cascade.toml`](../examples/multi-upstream-cascade.toml).
+
+## Production gaps (what compose *won't* give you)
+
+- **HA** — single instance; no leader election, no peer mesh
+- **TLS** — gRPC/HTTP are plaintext on the Docker network; terminate at a
+ reverse proxy or use `--cert` flags for mTLS on the proxy
+- **Auth** — `proxy.api_key` is required only if you set one; compose does
+ not ship one
+- **Persistence** — in-memory cache only; restart = cold cache. Enable
+ `persistence` (redb) and mount a volume for `/var/lib/conproxy/persist`
+- **Backups** — Meilisearch data only; conproxy has none to back up
+- **Observability** — add `--profile observability` and Prometheus/Grafana
+ or scrape `/metrics` from a sidecar
+
+For any of the above, use the
+[Helm chart](https://github.com/jmcgrath207/conproxy/pkgs/container/charts%2Fconproxy)
+or roll your own systemd/k8s manifests — see
+[`deployment.md`](deployment.md).
+
+## Troubleshooting
+
+| Symptom | Likely cause | Fix |
+|---------|--------------|-----|
+| `conproxy` exits immediately | `conproxy.toml` parse error | `docker compose logs conproxy` |
+| `connection refused` on `/query` | Listen on `127.0.0.1` inside container | `[server] listen = "0.0.0.0:9999"` (or pass `--listen`) |
+| Qdrant unhealthy | Slow first boot / OOM | Increase `interval`/`retries`; check `docker compose logs qdrant` |
+| Miss on every call | Empty Meilisearch index | Create the index + POST docs (see `examples/docker-compose/README.md`) |
+| `results: []` even after seeding | Meilisearch adapter defaulted to `search_attributes: ["content"]` — only the `content` field was searched | Fixed in this branch; if using v0.1.0 image, set `search_fields = ["title", "body", "content"]` in `conproxy.toml` |
+| Hit/miss ratio looks wrong | `cache_status` shape | See `/metrics` for `conproxy_cache_hit_rate` |
+
+ **Known issue (v0.1.0 image):** the Meilisearch adapter defaulted to
+`search_attributes: ["content"]`, so only the `content` field was searched.
+Documents with `title`/`body` but no `content` returned 0 results even
+though Meilisearch had matching docs. Fixed in this branch — empty
+`search_fields` now searches all fields. If using the v0.1.0 image, set
+`search_fields = ["title", "body", "content"]` in `conproxy.toml` or add
+`"content"` to your documents.
+
+## Reference
+
+- [`examples/docker-compose/docker-compose.yml`](../examples/docker-compose/docker-compose.yml) — the runnable file
+- [`examples/docker-compose/conproxy.toml`](../examples/docker-compose/conproxy.toml) — in-compose config
+- [`deployment.md`](deployment.md) — production deploys (Docker + systemd + k8s)
\ No newline at end of file
diff --git a/docs/quickstart.md b/docs/quickstart.md
index b58eec0..a51de35 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -56,3 +56,4 @@ conproxy stop
- **[Tour](tour.md)** — full feature walkthrough
- **[Configuration](configuration.md)** — all config fields
- **[CLI Reference](cli-reference.md)** — all commands and flags
+- **[Docker Compose](docker-compose.md)** — side-by-side proxy + backend stack (no Rust toolchain needed)
diff --git a/examples/README.md b/examples/README.md
index 84cf106..fcf4c8d 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -11,6 +11,7 @@ Runnable configs and integration snippets. Copy any file into `.conproxy/conprox
| `multi-upstream-cascade.toml` | Two-upstream priority cascade with RRF fusion. See [Multi-Upstream](../docs/multi-upstream.md#priority-based-cascade). |
| `federated-search.toml` | Local-first federated search with merge modes. See [Federated](../docs/multi-upstream.md#federated-search). |
| `multi-context.toml` | Two contexts share one Meili resource; isolated cache + scope. |
+| `docker-compose/` | Side-by-side `conproxy` + `meilisearch` stack with healthcheck. See [Docker Compose](../docs/docker-compose.md). |
| `mcp-claude-desktop.json` | Claude Desktop MCP server registration. See [MCP Integration](../docs/mcp-integration.md). |
| `mcp-opencode.jsonc` | opencode MCP server registration (global config). See [MCP Integration](../docs/mcp-integration.md). |
| `distill-postprocess.sh` | Post-process hook for `conproxy distill`. See [Distill](../docs/distill.md). |
diff --git a/examples/docker-compose/README.md b/examples/docker-compose/README.md
new file mode 100644
index 0000000..7e4832d
--- /dev/null
+++ b/examples/docker-compose/README.md
@@ -0,0 +1,82 @@
+# docker-compose example
+
+Runs **conproxy + Meilisearch** side by side with the published container
+image. Use this as a starting point; for production see
+[`../../docs/deployment.md`](../../docs/deployment.md) and the Helm chart.
+
+Meilisearch is chosen here because it's **text-native out of the box** — no
+embedder or FastEmbed config required for the demo flow. Swap it for
+Qdrant / Elasticsearch / pgvector (see `examples/multi-upstream-cascade.toml`)
+when you're ready for vector search.
+
+## What's in here
+
+| File | Role |
+|------|------|
+| `docker-compose.yml` | Two services (`meilisearch`, `conproxy`), healthcheck, pinned versions |
+| `conproxy.toml` | Context-rooted config; upstream uses Compose DNS (`http://meilisearch:7700`) |
+
+## Run
+
+```bash
+docker compose up -d
+```
+
+Wait for `conproxy-compose-proxy` to log `listening on 0.0.0.0:9999` (`docker compose logs -f conproxy`).
+
+## Smoke test
+
+```bash
+# 1. health
+curl -s http://127.0.0.1:10000/health
+
+# 2. create the index Meilisearch will search
+curl -s -X POST http://127.0.0.1:7700/indexes \
+ -H 'Authorization: Bearer dev_master_key' \
+ -H 'Content-Type: application/json' \
+ -d '{"uid": "docs", "primaryKey": "id"}'
+
+# 3. seed one doc (searchable fields auto-inferred on first hit)
+curl -s -X POST http://127.0.0.1:7700/indexes/docs/documents \
+ -H 'Authorization: Bearer dev_master_key' \
+ -H 'Content-Type: application/json' \
+ -d '[{"id": 1, "title": "Rust errors", "body": "how to handle errors in rust"}]'
+
+sleep 2 # let Meilisearch index
+
+# 4. first call — miss
+curl -s http://127.0.0.1:10000/query \
+ -H 'Content-Type: application/json' \
+ -d '{"query": "rust errors", "top_k": 5}'
+
+# 5. second call — hit
+curl -s http://127.0.0.1:10000/query \
+ -H 'Content-Type: application/json' \
+ -d '{"query": "rust errors", "top_k": 5}'
+```
+
+You should see `cache_status: "miss"` then `"hit"`.
+
+## Stop / reset
+
+```bash
+docker compose down # stop, keep volumes
+docker compose down --volumes # nuke Meilisearch storage too
+```
+
+## Customize
+
+- **Different release** — bump `conproxy` image tag in `docker-compose.yml`.
+- **Different backend** — swap `meilisearch` service (e.g. `qdrant`) and update
+ `conproxy.toml` (`type = "qdrant"`, URL `http://qdrant:6333`). Note: Qdrant
+ vector search requires FastEmbed or `query_mode = "vector_only"` + a local
+ embedder; Meilisearch is the path of least friction for a demo.
+- **Multi-backend cascade** — add more `[upstreams.*]` + `[[contexts.default.upstreams]]` entries; see [`../multi-upstream-cascade.toml`](../multi-upstream-cascade.toml).
+- **Persistent conproxy data** — if you enable the `persistence` feature, mount a volume for `redb` (advanced; default compose does not use disk-backed cache).
+
+## Not for production
+
+This example is **single-node**, **no TLS**, **master key in plain text**,
+**no auth on conproxy**, **no replication**. For HA + secrets + scale, use
+the Helm chart or roll your own systemd / k8s manifests. See
+[`docs/deployment.md`](../../docs/deployment.md).
\ No newline at end of file
diff --git a/examples/docker-compose/conproxy.toml b/examples/docker-compose/conproxy.toml
new file mode 100644
index 0000000..06f25d2
--- /dev/null
+++ b/examples/docker-compose/conproxy.toml
@@ -0,0 +1,29 @@
+# In-compose conproxy config: HTTP listen + Meilisearch via Compose DNS.
+#
+# - Listen on 0.0.0.0 so Docker port-mapping works.
+# - Upstream URL uses the Compose service name `meilisearch` (NOT localhost).
+# - Context-rooted (canonical): cache/scope/routing live on the context.
+# - Tunables (fresh_secs, stale_secs, max_entries) match the README Quick Start.
+
+[server]
+listen = "0.0.0.0:9999"
+
+[upstreams.meili]
+url = "http://meilisearch:7700"
+type = "meilisearch"
+index = "docs"
+# Must match MEILI_MASTER_KEY in docker-compose.yml.
+# Swap to "${MEILI_MASTER_KEY}" in production to read from env.
+api_key = "dev_master_key"
+timeout_secs = 30
+
+[contexts.default]
+default = true
+
+[[contexts.default.upstreams]]
+ref = "meili"
+
+[contexts.default.cache]
+fresh_secs = 300 # 5 min fresh
+stale_secs = 600 # 10 min stale (serves while refreshing)
+max_entries = 10000
\ No newline at end of file
diff --git a/examples/docker-compose/docker-compose.yml b/examples/docker-compose/docker-compose.yml
new file mode 100644
index 0000000..d856048
--- /dev/null
+++ b/examples/docker-compose/docker-compose.yml
@@ -0,0 +1,60 @@
+# conproxy + Meilisearch, side by side.
+#
+# Pin to a specific release for reproducibility; bump on purpose.
+# Meilisearch is text-native out of the box — no FastEmbed / embedder
+# config required for the demo flow.
+#
+# Usage:
+# docker compose up -d
+# curl -s http://127.0.0.1:10000/health
+# # query twice — second call hits the cache
+# curl -s http://127.0.0.1:10000/query \
+# -H 'Content-Type: application/json' \
+# -d '{"query": "how to handle errors in rust", "top_k": 5}'
+# docker compose down
+
+services:
+ meilisearch:
+ # Meilisearch is text-native out of the box — no embedder setup needed
+ # for the demo flow.
+ image: getmeili/meilisearch:v1.8
+ container_name: conproxy-compose-meili
+ environment:
+ MEILI_NO_ANALYTICS: "true"
+ MEILI_ENV: development
+ # Master key is required for writes (creating the index, seeding docs)
+ # but conproxy's `api_key` below matches it so the proxy can search.
+ MEILI_MASTER_KEY: dev_master_key
+ volumes:
+ - meili_data:/meili_data
+ ports:
+ - "7700:7700"
+ healthcheck:
+ # Meilisearch exposes a JSON liveness at /health; -O /dev/null avoids
+ # logging the response body to the health log.
+ test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:7700/health || exit 1"]
+ interval: 5s
+ timeout: 3s
+ retries: 20
+ start_period: 20s
+
+ conproxy:
+ # Match the published release; :latest is fine for local play but unstable.
+ image: ghcr.io/jmcgrath207/conproxy:0.1.0
+ container_name: conproxy-compose-proxy
+ depends_on:
+ meilisearch:
+ condition: service_healthy
+ ports:
+ - "9999:9999" # gRPC
+ - "10000:10000" # HTTP REST (query, health, metrics, admin)
+ volumes:
+ - ./conproxy.toml:/etc/conproxy/conproxy.toml:ro
+ command: ["start", "--config", "/etc/conproxy/conproxy.toml", "--listen", "0.0.0.0:9999"]
+ # Non-root by default; no extra caps required for the proxy.
+ security_opt:
+ - no-new-privileges:true
+ restart: unless-stopped
+
+volumes:
+ meili_data:
\ No newline at end of file
diff --git a/src/proxy/meilisearch.rs b/src/proxy/meilisearch.rs
index 1e8dad1..57340fa 100644
--- a/src/proxy/meilisearch.rs
+++ b/src/proxy/meilisearch.rs
@@ -11,9 +11,9 @@
//! setting is enabled. The score is already in the 0-1 range, so no
//! division by `max_score` is needed (unlike Elasticsearch BM25).
//!
-//! **This adapter requires Meilisearch v1.0 or newer** and the index
-//! must have `showRankingScore` enabled (the adapter enables it
-//! implicitly on the first query if not already set on the index).
+//! **This adapter requires Meilisearch v1.0 or newer.** The adapter
+//! requests `showRankingScore` on every query; the index must have it
+//! enabled (Meili v1.0+ enables it by default for new indexes).
use std::sync::atomic::{AtomicU8, Ordering};
use std::time::Duration;
@@ -37,7 +37,7 @@ pub struct MeilisearchConfig {
pub index: String,
/// Request timeout.
pub timeout: Duration,
- /// Attributes to search on (default: ["content"]).
+ /// Attributes to search on (default: empty = search all fields).
pub search_attributes: Vec,
/// Attributes to return in hits (default: empty = all attributes).
pub displayed_attributes: Vec,
@@ -56,7 +56,7 @@ impl Default for MeilisearchConfig {
base_url: "http://localhost:7700".to_string(),
index: "documents".to_string(),
timeout: Duration::from_secs(30),
- search_attributes: vec!["content".to_string()],
+ search_attributes: Vec::new(),
displayed_attributes: Vec::new(),
api_key: None,
score_threshold: None,
@@ -199,7 +199,7 @@ impl MeilisearchAdapter {
.hits
.iter()
.filter_map(|hit| {
- let id = hit.id().to_string();
+ let id = hit_id(hit);
// Extract content from common field names.
let content = hit
@@ -270,25 +270,14 @@ struct MeiliVersionResponse {
pkg_version: String,
}
-/// Trait extension for `serde_json::Value` to retrieve a hit's `id` and
+/// Trait extension for `serde_json::Value` to retrieve a hit's
/// `_rankingScore` regardless of whether the user used the reserved field
/// name or a custom primary key.
trait MeiliHitExt {
- fn id(&self) -> &str;
fn ranking_score(&self) -> Option;
}
impl MeiliHitExt for serde_json::Value {
- fn id(&self) -> &str {
- // Meili usually returns the primary key as a top-level field.
- // If absent, fall back to the integer id (we don't currently use
- // integer ids in our schemas, so this is mostly defensive).
- self.get("id")
- .and_then(|v| v.as_str())
- .or_else(|| self.get("uid").and_then(|v| v.as_str()))
- .unwrap_or_default()
- }
-
fn ranking_score(&self) -> Option {
self.get("_rankingScore")
.and_then(|v| v.as_f64())
@@ -296,6 +285,24 @@ impl MeiliHitExt for serde_json::Value {
}
}
+/// Owned id string extracted from a Meili hit, handling both string and
+/// numeric primary keys.
+fn hit_id(hit: &serde_json::Value) -> String {
+ hit.get("id")
+ .and_then(|v| {
+ v.as_str()
+ .map(|s| s.to_string())
+ .or_else(|| v.as_i64().map(|n| n.to_string()))
+ .or_else(|| v.as_u64().map(|n| n.to_string()))
+ .or_else(|| v.as_f64().map(|n| n.to_string()))
+ })
+ .or_else(|| {
+ hit.get("uid")
+ .and_then(|v| v.as_str().map(|s| s.to_string()))
+ })
+ .unwrap_or_default()
+}
+
#[async_trait]
impl UpstreamAdapter for MeilisearchAdapter {
async fn query(&self, request: &QueryRequest) -> Result {
diff --git a/src/proxy/pool.rs b/src/proxy/pool.rs
index ef48985..73f0bd4 100644
--- a/src/proxy/pool.rs
+++ b/src/proxy/pool.rs
@@ -142,7 +142,7 @@ fn create_adapter(config: &UpstreamEndpointConfig) -> Result::new());
assert!(config.displayed_attributes.is_empty());
assert!(config.api_key.is_none());
assert!(config.score_threshold.is_none());
@@ -205,7 +205,8 @@ fn test_meilisearch_build_query_body() {
assert_eq!(body["q"], "rust async");
assert_eq!(body["limit"], 5);
assert_eq!(body["showRankingScore"], true);
- assert_eq!(body["attributesToSearchOn"][0], "content");
+ // Default search_attributes is empty → no attributesToSearchOn sent.
+ assert!(body.get("attributesToSearchOn").is_none());
}
#[test]
@@ -246,13 +247,47 @@ fn test_meilisearch_version_response_parsing() {
#[test]
fn test_meilisearch_helpers_extract_id() {
let v = serde_json::json!({"id": "doc-1", "content": "x"});
- assert_eq!(v.id(), "doc-1");
+ assert_eq!(hit_id(&v), "doc-1");
let v = serde_json::json!({"uid": "doc-2", "content": "y"});
- assert_eq!(v.id(), "doc-2");
+ assert_eq!(hit_id(&v), "doc-2");
let v = serde_json::json!({"content": "no id"});
- assert_eq!(v.id(), "");
+ assert_eq!(hit_id(&v), "");
+}
+
+#[test]
+fn test_meilisearch_helpers_extract_id_numeric() {
+ // Meili returns integer primary keys as JSON numbers.
+ let v = serde_json::json!({"id": 1, "body": "x"});
+ assert_eq!(hit_id(&v), "1");
+
+ let v = serde_json::json!({"id": 42, "body": "y"});
+ assert_eq!(hit_id(&v), "42");
+
+ // String ids still work.
+ let v = serde_json::json!({"id": "doc-1", "body": "z"});
+ assert_eq!(hit_id(&v), "doc-1");
+
+ // Missing id → empty.
+ let v = serde_json::json!({"body": "no id"});
+ assert_eq!(hit_id(&v), "");
+}
+
+#[test]
+fn test_meilisearch_parse_hit_with_numeric_id() {
+ // Full parse: numeric id + body content → hit kept.
+ let meili_json = serde_json::json!({
+ "hits": [{"id": 1, "body": "rust errors", "_rankingScore": 0.9}],
+ "estimatedTotalHits": 1,
+ "processingTimeMs": 1
+ });
+ let parsed: MeiliSearchResponse = serde_json::from_value(meili_json).unwrap();
+ let results = MeilisearchAdapter::parse_hits(&parsed);
+ assert_eq!(results.len(), 1);
+ assert_eq!(results[0].id, "1");
+ assert_eq!(results[0].content, "rust errors");
+ assert!((results[0].score - 0.9).abs() < 0.001);
}
#[test]