Packages

Production adapters and pipelines for PortfolioCore. Vector stores, graph stores, embedders, Broadway pipelines, and advanced RAG strategies.

Current section

6 Versions

Jump to

Compare versions

21 files changed
+3024 additions
-712 deletions
  @@ -7,6 +7,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7 7
8 8 ## [Unreleased]
9 9
10 + ## [0.4.0] - 2026-01-08
11 +
12 + ### Added
13 +
14 + #### Local LLM Support
15 + - `PortfolioIndex.Adapters.LLM.Ollama` - Ollama LLM adapter using ollixir client
16 + - Local model orchestration with configurable base URL
17 + - OpenAI-style message formatting
18 + - Streaming support with delta normalization
19 + - Rate limiting and telemetry instrumentation
20 + - Configurable model info and supported models list
21 + - `PortfolioIndex.Adapters.LLM.VLLM` - vLLM adapter for OpenAI-compatible endpoints
22 + - Uses openai_ex for OpenAI-compatible API
23 + - Configurable base URL for local clusters
24 + - SSE stream parsing with finish reason normalization
25 + - Rate limiting and failure detection
26 +
27 + #### Local Embeddings Support
28 + - `PortfolioIndex.Adapters.Embedder.Ollama` - Ollama embeddings adapter using ollixir
29 + - Support for nomic-embed-text, mxbai-embed-large models
30 + - Batch embedding support via native API
31 + - Token count extraction from response or estimation fallback
32 + - Rate limiting and telemetry instrumentation
33 +
34 + #### Telemetry Enhancements
35 + - `PortfolioIndex.Telemetry.Context` - Lineage context helpers for telemetry metadata
36 + - Standard context keys: `trace_id`, `work_id`, `plan_id`, `step_id`
37 + - `merge/2` for combining context with telemetry metadata
38 + - Integrated into all LLM adapters (Anthropic, Codex, Gemini, OpenAI, Ollama, vLLM)
39 +
40 + #### Backend Integration
41 + - `PortfolioIndex.LLM.BackendBridge` - Bridge for CrucibleIR backend prompts
42 + - `prompt_to_messages/1` - Convert backend prompt structs to messages and opts
43 + - `completion_from_result/3` - Build completion maps from adapter results
44 + - Usage normalization across provider formats
45 +
46 + #### Examples
47 + - `examples/ollama_embedder.exs` - Ollama embedder demonstration
48 + - `examples/ollama_llm.exs` - Ollama LLM with streaming demonstration
49 + - `examples/ollama_setup.exs` - Automated Ollama model installation script
50 + - `examples/vllm_llm.exs` - vLLM adapter demonstration
51 + - `examples/support/ollama_helpers.exs` - Shared helpers for Ollama examples
52 +
53 + ### Changed
54 +
55 + - LLM adapters now pass opts to telemetry for lineage context propagation
56 + - Gemini embedder and LLM adapters support SDK injection via `:sdk` option
57 + - RateLimiter uses rescue instead of try for ETS table creation
58 + - Updated run_all.sh to include Ollama examples (vLLM skipped by default)
59 +
60 + ### Dependencies
61 +
62 + - Changed portfolio_core from path to hex: `~> 0.4.0`
63 + - Changed foundation from path to hex: `~> 0.2.0`
64 + - Changed gemini_ex from path to hex: `~> 0.8.8`
65 + - Changed claude_agent_sdk from path to hex: `~> 0.7.6`
66 + - Changed codex_sdk from path to hex: `~> 0.5.0`
67 + - Added ollixir `~> 0.1.0` for Ollama integration
68 +
10 69 ## [0.3.1] - 2025-12-30
11 70
12 71 ### Added
  @@ -367,7 +426,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
367 426
368 427 - Initial release of PortfolioIndex
369 428
370 - [Unreleased]: https://github.com/nshkrdotcom/portfolio_index/compare/v0.3.1...HEAD
429 + [Unreleased]: https://github.com/nshkrdotcom/portfolio_index/compare/v0.4.0...HEAD
430 + [0.4.0]: https://github.com/nshkrdotcom/portfolio_index/compare/v0.3.1...v0.4.0
371 431 [0.3.1]: https://github.com/nshkrdotcom/portfolio_index/compare/v0.3.0...v0.3.1
372 432 [0.3.0]: https://github.com/nshkrdotcom/portfolio_index/compare/v0.2.0...v0.3.0
373 433 [0.2.0]: https://github.com/nshkrdotcom/portfolio_index/compare/v0.1.1...v0.2.0
  @@ -22,7 +22,8 @@ Portfolio Index implements the port specifications defined in [Portfolio Core](h
22 22 - **Vector Store Adapters** - pgvector (PostgreSQL + fulltext hybrid)
23 23 - **Graph Store Adapters** - Neo4j via boltx + community operations
24 24 - **Embedding Providers** - Google Gemini
25 - - **LLM Providers** - Google Gemini, Anthropic Claude, OpenAI GPT/o1
25 + - **LLM Providers** - Google Gemini, Anthropic Claude, OpenAI (openai_ex), Codex (codex_sdk),
26 + Ollama, vLLM (OpenAI-compatible)
26 27 - **Broadway Pipelines** - Ingestion and embedding with backpressure
27 28 - **RAG Strategies** - Hybrid (RRF fusion), Self-RAG (self-critique), GraphRAG, Agentic
28 29
  @@ -75,7 +76,7 @@ Add `portfolio_index` to your list of dependencies in `mix.exs`:
75 76 ```elixir
76 77 def deps do
77 78 [
78 - {:portfolio_index, "~> 0.3.1"}
79 + {:portfolio_index, "~> 0.4.0"}
79 80 ]
80 81 end
81 82 ```
  @@ -199,6 +200,37 @@ alias PortfolioIndex.RAG.Strategies.SelfRAG
199 200 | `NEO4J_USER` | Neo4j username | `neo4j` |
200 201 | `NEO4J_PASSWORD` | Neo4j password | - |
201 202 | `GEMINI_API_KEY` | Google Gemini API key | - |
203 + | `OPENAI_API_KEY` | OpenAI API key (OpenAI + Codex) | - |
204 + | `OPENAI_ORGANIZATION` | OpenAI organization ID (optional) | - |
205 + | `ANTHROPIC_API_KEY` | Anthropic API key | - |
206 + | `CODEX_API_KEY` | Codex SDK API key (optional) | - |
207 + | `OLLAMA_HOST` | Ollama host URL | `http://localhost:11434` |
208 + | `OLLAMA_BASE_URL` | Ollama base URL (override) | `http://localhost:11434/api` |
209 + | `OLLAMA_API_KEY` | Ollama API key (optional) | - |
210 + | `VLLM_BASE_URL` | vLLM base URL | `http://localhost:8000/v1` |
211 + | `VLLM_API_KEY` | vLLM API key (optional) | - |
212 +
213 + ### Local Model Setup
214 +
215 + Ollama examples require a running Ollama server and these models:
216 +
217 + - `llama3.2` (LLM)
218 + - `nomic-embed-text` (embeddings)
219 +
220 + Install them with:
221 +
222 + ```bash
223 + ollama pull llama3.2
224 + ollama pull nomic-embed-text
225 + ```
226 +
227 + Or run:
228 +
229 + ```bash
230 + mix run examples/ollama_setup.exs
231 + ```
232 +
233 + The vLLM example expects a vLLM server running at `VLLM_BASE_URL`.
202 234
203 235 ### Config Files
204 236
  @@ -233,23 +265,32 @@ config :boltx, Boltx,
233 265 ### Embedders
234 266
235 267 - **Gemini** - Google Gemini text-embedding-004
268 + - **OpenAI** - text-embedding-3-small/large
269 + - **Ollama** - Local embeddings via ollixir
236 270
237 271 | Adapter | Provider | Model |
238 272 |---------|----------|-------|
239 273 | `Gemini` | Google | text-embedding-004 (768 dims) |
240 - | `OpenAI` | OpenAI | text-embedding-3-small/large (placeholder) |
274 + | `OpenAI` | OpenAI | text-embedding-3-small/large |
275 + | `Ollama` | Ollama | nomic-embed-text (default) |
241 276
242 277 ### LLMs
243 278
244 - - **Gemini** - gemini-2.5-flash with streaming
279 + - **Gemini** - gemini-flash-lite-latest with streaming
245 280 - **Anthropic** - Claude via claude_agent_sdk
246 - - **OpenAI** - GPT/o1 via codex_sdk
281 + - **OpenAI** - GPT-4o-mini (low-cost default) via openai_ex
282 + - **Codex** - OpenAI Codex SDK with agentic support
283 + - **Ollama** - Local models via ollixir
284 + - **vLLM** - OpenAI-compatible endpoints via openai_ex
247 285
248 286 | Adapter | Provider | Model |
249 287 |---------|----------|-------|
250 - | `Gemini` | Google | gemini-2.5-flash |
288 + | `Gemini` | Google | gemini-flash-lite-latest |
251 289 | `Anthropic` | Anthropic | Claude (SDK default) |
252 - | `OpenAI` | OpenAI | GPT/o1 (SDK default) |
290 + | `OpenAI` | OpenAI | gpt-4o-mini (default) |
291 + | `Codex` | OpenAI | Codex SDK default |
292 + | `Ollama` | Ollama | llama3.2 (default) |
293 + | `VLLM` | vLLM | llama3 (default) |
253 294
254 295 ### Chunker
255 296
  @@ -308,9 +349,9 @@ Recursive.chunk(text, :plain, %{
308 349
309 350 ```
310 351 ┌─────────────────────────────────────────────────────────────┐
311 - │ Portfolio Index │
352 + │ Portfolio Index │
312 353 ├─────────────────────────────────────────────────────────────┤
313 - │ Adapters │
354 + │ Adapters │
314 355 │ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
315 356 │ │ Vector Store │ │ Graph Store │ │ Embedder │ │
316 357 │ │ • Pgvector │ │ • Neo4j │ │ • Gemini │ │
  @@ -320,15 +361,19 @@ Recursive.chunk(text, :plain, %{
320 361 │ │ LLM │ │ Chunker │ │ Document Store│ │
321 362 │ │ • Gemini │ │ • Recursive │ │ • Postgres │ │
322 363 │ │ • Anthropic │ │ │ │ │ │
364 + │ │ • OpenAI │ │ │ │ │ │
365 + │ │ • Codex │ │ │ │ │ │
366 + │ │ • Ollama │ │ │ │ │ │
367 + │ │ • vLLM │ │ │ │ │ │
323 368 │ └───────────────┘ └───────────────┘ └───────────────┘ │
324 369 ├─────────────────────────────────────────────────────────────┤
325 - │ Pipelines (Broadway) │
370 + │ Pipelines (Broadway) │
326 371 │ ┌───────────────────────────┐ ┌───────────────────────────┐│
327 372 │ │ Ingestion │ │ Embedding ││
328 373 │ │ FileProducer → Chunker │ │ ETSProducer → VectorStore ││
329 374 │ └───────────────────────────┘ └───────────────────────────┘│
330 375 ├─────────────────────────────────────────────────────────────┤
331 - │ RAG Strategies │
376 + │ RAG Strategies │
332 377 │ ┌────────────────────┐ ┌────────────────────┐ │
333 378 │ │ Hybrid │ │ Self-RAG │ │
334 379 │ │ Vector + RRF fusion│ │ Critique + Refine │ │
  @@ -341,8 +386,8 @@ Recursive.chunk(text, :plain, %{
341 386
342 387
343 388 ┌─────────────────────────────────────────────────────────────┐
344 - │ Portfolio Core │
345 - │ (Port Specifications & Registry) │
389 + │ Portfolio Core │
390 + │ (Port Specifications & Registry) │
346 391 └─────────────────────────────────────────────────────────────┘
347 392 ```
348 393
  @@ -808,6 +853,20 @@ Attach handlers for monitoring:
808 853 - [`portfolio_core`](https://github.com/nshkrdotcom/portfolio_core) - Hexagonal architecture primitives
809 854 - [`portfolio_manager`](https://github.com/nshkrdotcom/portfolio_manager) - CLI and application layer
810 855
856 + ## Acknowledgments
857 +
858 + Significant portions of this library's architecture and features were derived from
859 + analysis of [Arcana](https://github.com/georgeguimaraes/arcana) by George Guimarães,
860 + licensed under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0).
861 +
862 + Features inspired by Arcana include:
863 + - RAG pipeline architecture (query rewriting, expansion, decomposition)
864 + - Evaluation system design (IR metrics, test case generation)
865 + - Chunker token utilities and sizing options
866 + - Telemetry patterns and agent system design
867 +
868 + See `docs/20251230/arcana_gap_analysis/` for detailed analysis.
869 +
811 870 ## License
812 871
813 872 MIT License - see [LICENSE](LICENSE) for details.
  @@ -1,7 +1,7 @@
1 1 {<<"links">>,
2 2 [{<<"GitHub">>,<<"https://github.com/nshkrdotcom/portfolio_index">>}]}.
3 3 {<<"name">>,<<"portfolio_index">>}.
4 - {<<"version">>,<<"0.3.1">>}.
4 + {<<"version">>,<<"0.4.0">>}.
5 5 {<<"description">>,
6 6 <<"Production adapters and pipelines for PortfolioCore. Vector stores, graph stores, embedders, Broadway pipelines, and advanced RAG strategies.">>}.
7 7 {<<"elixir">>,<<"~> 1.15">>}.
  @@ -58,12 +58,17 @@
58 58 <<"lib/portfolio_index/adapters/embedder">>,
59 59 <<"lib/portfolio_index/adapters/embedder/function.ex">>,
60 60 <<"lib/portfolio_index/adapters/embedder/bumblebee.ex">>,
61 + <<"lib/portfolio_index/adapters/embedder/ollama.ex">>,
61 62 <<"lib/portfolio_index/adapters/embedder/gemini.ex">>,
62 63 <<"lib/portfolio_index/adapters/embedder/open_ai.ex">>,
64 + <<"lib/portfolio_index/adapters/rate_limiter.ex">>,
63 65 <<"lib/portfolio_index/adapters/llm">>,
66 + <<"lib/portfolio_index/adapters/llm/vllm.ex">>,
67 + <<"lib/portfolio_index/adapters/llm/openai.ex">>,
64 68 <<"lib/portfolio_index/adapters/llm/anthropic.ex">>,
69 + <<"lib/portfolio_index/adapters/llm/codex.ex">>,
70 + <<"lib/portfolio_index/adapters/llm/ollama.ex">>,
65 71 <<"lib/portfolio_index/adapters/llm/gemini.ex">>,
66 - <<"lib/portfolio_index/adapters/llm/open_ai.ex">>,
67 72 <<"lib/portfolio_index/adapters/vector_store">>,
68 73 <<"lib/portfolio_index/adapters/vector_store/pgvector">>,
69 74 <<"lib/portfolio_index/adapters/vector_store/pgvector/hybrid.ex">>,
  @@ -77,6 +82,7 @@
77 82 <<"lib/portfolio_index/telemetry/vector_store.ex">>,
78 83 <<"lib/portfolio_index/telemetry/logger.ex">>,
79 84 <<"lib/portfolio_index/telemetry/llm.ex">>,
85 + <<"lib/portfolio_index/telemetry/context.ex">>,
80 86 <<"lib/portfolio_index/embedder">>,
81 87 <<"lib/portfolio_index/embedder/dimension_detector.ex">>,
82 88 <<"lib/portfolio_index/embedder/registry.ex">>,
  @@ -107,6 +113,8 @@
107 113 <<"lib/portfolio_index/rag/strategies/hybrid.ex">>,
108 114 <<"lib/portfolio_index/rag/strategies/self_rag.ex">>,
109 115 <<"lib/portfolio_index/rag/strategies/graph_rag.ex">>,
116 + <<"lib/portfolio_index/llm">>,
117 + <<"lib/portfolio_index/llm/backend_bridge.ex">>,
110 118 <<"lib/portfolio_index/vector_store">>,
111 119 <<"lib/portfolio_index/vector_store/backend.ex">>,
112 120 <<"lib/portfolio_index/vector_store/collections.ex">>,
  @@ -120,7 +128,12 @@
120 128 [[{<<"name">>,<<"portfolio_core">>},
121 129 {<<"app">>,<<"portfolio_core">>},
122 130 {<<"optional">>,false},
123 - {<<"requirement">>,<<"~> 0.3.1">>},
131 + {<<"requirement">>,<<"~> 0.4.0">>},
132 + {<<"repository">>,<<"hexpm">>}],
133 + [{<<"name">>,<<"foundation">>},
134 + {<<"app">>,<<"foundation">>},
135 + {<<"optional">>,false},
136 + {<<"requirement">>,<<"~> 0.2.0">>},
124 137 {<<"repository">>,<<"hexpm">>}],
125 138 [{<<"name">>,<<"ecto_sql">>},
126 139 {<<"app">>,<<"ecto_sql">>},
  @@ -155,17 +168,27 @@
155 168 [{<<"name">>,<<"claude_agent_sdk">>},
156 169 {<<"app">>,<<"claude_agent_sdk">>},
157 170 {<<"optional">>,false},
158 - {<<"requirement">>,<<"~> 0.7.2">>},
171 + {<<"requirement">>,<<"~> 0.7.6">>},
159 172 {<<"repository">>,<<"hexpm">>}],
160 173 [{<<"name">>,<<"codex_sdk">>},
161 174 {<<"app">>,<<"codex_sdk">>},
162 175 {<<"optional">>,false},
163 - {<<"requirement">>,<<"~> 0.4.5">>},
176 + {<<"requirement">>,<<"~> 0.5.0">>},
177 + {<<"repository">>,<<"hexpm">>}],
178 + [{<<"name">>,<<"ollixir">>},
179 + {<<"app">>,<<"ollixir">>},
180 + {<<"optional">>,false},
181 + {<<"requirement">>,<<"~> 0.1.0">>},
182 + {<<"repository">>,<<"hexpm">>}],
183 + [{<<"name">>,<<"openai_ex">>},
184 + {<<"app">>,<<"openai_ex">>},
185 + {<<"optional">>,false},
186 + {<<"requirement">>,<<"~> 0.9.18">>},
164 187 {<<"repository">>,<<"hexpm">>}],
165 188 [{<<"name">>,<<"req">>},
166 189 {<<"app">>,<<"req">>},
167 190 {<<"optional">>,false},
168 - {<<"requirement">>,<<"~> 0.4">>},
191 + {<<"requirement">>,<<"~> 0.5">>},
169 192 {<<"repository">>,<<"hexpm">>}],
170 193 [{<<"name">>,<<"finch">>},
171 194 {<<"app">>,<<"finch">>},
  @@ -182,11 +205,6 @@
182 205 {<<"optional">>,false},
183 206 {<<"requirement">>,<<"~> 1.2">>},
184 207 {<<"repository">>,<<"hexpm">>}],
185 - [{<<"name">>,<<"hammer">>},
186 - {<<"app">>,<<"hammer">>},
187 - {<<"optional">>,false},
188 - {<<"requirement">>,<<"~> 6.1">>},
189 - {<<"repository">>,<<"hexpm">>}],
190 208 [{<<"name">>,<<"telemetry">>},
191 209 {<<"app">>,<<"telemetry">>},
192 210 {<<"optional">>,false},
  @@ -195,7 +213,7 @@
195 213 [{<<"name">>,<<"telemetry_metrics">>},
196 214 {<<"app">>,<<"telemetry_metrics">>},
197 215 {<<"optional">>,false},
198 - {<<"requirement">>,<<"~> 0.6">>},
216 + {<<"requirement">>,<<"~> 1.1">>},
199 217 {<<"repository">>,<<"hexpm">>}],
200 218 [{<<"name">>,<<"telemetry_poller">>},
201 219 {<<"app">>,<<"telemetry_poller">>},
  @@ -13,9 +13,13 @@ defmodule PortfolioIndex do
13 13 (hybrid fulltext via `PortfolioIndex.Adapters.VectorStore.Pgvector.Hybrid`)
14 14 - Graph Stores: `PortfolioIndex.Adapters.GraphStore.Neo4j`
15 15 (community ops via `PortfolioIndex.Adapters.GraphStore.Neo4j.Community`)
16 - - Embedders: `PortfolioIndex.Adapters.Embedder.Gemini`
16 + - Embedders: `PortfolioIndex.Adapters.Embedder.Gemini`,
17 + `PortfolioIndex.Adapters.Embedder.OpenAI`,
18 + `PortfolioIndex.Adapters.Embedder.Ollama`,
19 + `PortfolioIndex.Adapters.Embedder.Bumblebee`
17 20 - LLMs: `PortfolioIndex.Adapters.LLM.Gemini`, `PortfolioIndex.Adapters.LLM.Anthropic`,
18 - `PortfolioIndex.Adapters.LLM.OpenAI`
21 + `PortfolioIndex.Adapters.LLM.OpenAI`, `PortfolioIndex.Adapters.LLM.Codex`,
22 + `PortfolioIndex.Adapters.LLM.Ollama`, `PortfolioIndex.Adapters.LLM.VLLM`
19 23 - Chunkers: `PortfolioIndex.Adapters.Chunker.Recursive`
20 24 - Document Stores: `PortfolioIndex.Adapters.DocumentStore.Postgres`
  @@ -37,10 +37,15 @@ defmodule PortfolioIndex.Adapters.Embedder.Gemini do
37 37 require Logger
38 38 alias Gemini.Types.Response.ContentEmbedding
39 39 alias Gemini.Types.Response.EmbedContentResponse
40 + alias PortfolioIndex.Adapters.RateLimiter
40 41
41 42 @impl true
42 43 def embed(text, opts) do
44 + # Wait for rate limiter before making request
45 + RateLimiter.wait(:gemini, :embedding)
46 +
43 47 start_time = System.monotonic_time(:millisecond)
48 + sdk = resolve_sdk(opts)
44 49 {model_opt, effective_model} = resolve_embedding_model(opts)
45 50 dims = resolve_dimensions(opts, effective_model)
46 51
  @@ -48,11 +53,13 @@ defmodule PortfolioIndex.Adapters.Embedder.Gemini do
48 53 opts
49 54 |> Keyword.delete(:model)
50 55 |> Keyword.delete(:dimensions)
56 + |> Keyword.delete(:sdk)
51 57 |> Keyword.put(:output_dimensionality, dims)
52 58 |> maybe_put(:model, model_opt)
53 59
54 - case Gemini.embed_content(text, gemini_opts) do
60 + case sdk.embed_content(text, gemini_opts) do
55 61 {:ok, response} ->
62 + RateLimiter.record_success(:gemini, :embedding)
56 63 vector = extract_embedding(response)
57 64 normalized_vector = maybe_normalize(vector, effective_model, dims)
58 65
  @@ -78,6 +85,8 @@ defmodule PortfolioIndex.Adapters.Embedder.Gemini do
78 85 }}
79 86
80 87 {:error, reason} ->
88 + failure_type = detect_failure_type(reason)
89 + RateLimiter.record_failure(:gemini, :embedding, failure_type)
81 90 Logger.error("Embedding failed: #{inspect(reason)}")
82 91 {:error, reason}
83 92 end
  @@ -191,6 +200,14 @@ defmodule PortfolioIndex.Adapters.Embedder.Gemini do
191 200 defp maybe_put(opts, _key, nil), do: opts
192 201 defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value)
193 202
203 + defp gemini_module do
204 + Application.get_env(:portfolio_index, :gemini_sdk, Gemini)
205 + end
206 +
207 + defp resolve_sdk(opts) do
208 + Keyword.get(opts, :sdk) || gemini_module()
209 + end
210 +
194 211 defp maybe_normalize(vector, model, dims) do
195 212 if Gemini.Config.needs_normalization?(model, dims) do
196 213 normalize(vector)
  @@ -224,4 +241,19 @@ defmodule PortfolioIndex.Adapters.Embedder.Gemini do
224 241 metadata
225 242 )
226 243 end
244 +
245 + defp detect_failure_type(reason) do
246 + reason_str = inspect(reason) |> String.downcase()
247 +
248 + cond do
249 + String.contains?(reason_str, "rate") or String.contains?(reason_str, "429") ->
250 + :rate_limited
251 +
252 + String.contains?(reason_str, "timeout") ->
253 + :timeout
254 +
255 + true ->
256 + :server_error
257 + end
258 + end
227 259 end
Loading more files…