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]