docs: graduate TODO/FINDINGS into production documentation
Replace internal task tracking (TODO.md, FINDINGS.md) with structured documentation in docs/. Trim CLAUDE.md to agent instructions only. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
f5f1e68c5c
commit
ce4e311b54
6 changed files with 670 additions and 420 deletions
71
CLAUDE.md
71
CLAUDE.md
|
|
@ -1,54 +1,69 @@
|
|||
# CLAUDE.md
|
||||
|
||||
## What This Is
|
||||
Module: `forge.lthn.ai/core/go-rag`
|
||||
|
||||
Retrieval-Augmented Generation with vector search. Module: `forge.lthn.ai/core/go-rag`
|
||||
|
||||
Provides document chunking, embedding via Ollama, vector storage/search via Qdrant, and formatted context retrieval for AI prompts.
|
||||
Retrieval-Augmented Generation — document chunking, Ollama embeddings, Qdrant vector storage and search.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
go test ./... # Run all tests
|
||||
go test -v -run TestChunk # Single test
|
||||
go test ./... # Unit + mock tests (no services needed)
|
||||
go test -tags rag ./... # Full suite including live Qdrant + Ollama
|
||||
go test -v -run TestName ./... # Single test
|
||||
go test -bench=. -benchmem ./... # Benchmarks (mock-only)
|
||||
go test -tags rag -bench=. ./... # Benchmarks with live services
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `chunk.go` | Document chunking — splits markdown/text into semantic chunks |
|
||||
| `embedder.go` | `Embedder` interface — abstraction for embedding providers |
|
||||
| `vectorstore.go` | `VectorStore` interface — abstraction for vector storage backends |
|
||||
| `ingest.go` | Ingestion pipeline — reads files, chunks, embeds, stores (accepts interfaces) |
|
||||
| `query.go` | Query interface — search vectors, format results as text/JSON/XML (accepts interfaces) |
|
||||
| `qdrant.go` | Qdrant vector DB client — implements `VectorStore` |
|
||||
| `ollama.go` | Ollama embedding client — implements `Embedder` |
|
||||
| `helpers.go` | Convenience wrappers — `*With` variants accept interfaces, defaults construct live clients |
|
||||
| `embedder.go` | `Embedder` interface |
|
||||
| `vectorstore.go` | `VectorStore` interface + `CollectionInfo` |
|
||||
| `chunk.go` | Markdown chunking — sections, paragraphs, sentences, overlap |
|
||||
| `ollama.go` | `OllamaClient` — implements `Embedder` |
|
||||
| `qdrant.go` | `QdrantClient` — implements `VectorStore` |
|
||||
| `ingest.go` | Ingestion pipeline |
|
||||
| `query.go` | Query pipeline + result formatting |
|
||||
| `keyword.go` | Keyword boosting post-filter |
|
||||
| `collections.go` | Collection management helpers |
|
||||
| `helpers.go` | Convenience wrappers (`*With` and default-client variants) |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `forge.lthn.ai/core/go` — Logging (pkg/log)
|
||||
- `github.com/ollama/ollama` — Embedding API client
|
||||
- `github.com/qdrant/go-client` — Vector DB gRPC client
|
||||
- `github.com/stretchr/testify` — Tests
|
||||
See `docs/architecture.md` for full design detail.
|
||||
|
||||
## Key API
|
||||
|
||||
```go
|
||||
// Ingest documents
|
||||
rag.IngestFile(ctx, cfg, "/path/to/doc.md")
|
||||
rag.Ingest(ctx, cfg, reader, "source-name")
|
||||
// Ingest a directory (interface-accepting variant)
|
||||
IngestDirWith(ctx, store, embedder, directory, collectionName string, recreate bool) error
|
||||
|
||||
// Ingest a single file
|
||||
IngestFileWith(ctx, store, embedder, filePath, collectionName string) (int, error)
|
||||
|
||||
// Query for relevant context
|
||||
results, err := rag.Query(ctx, cfg, "search query")
|
||||
context := rag.FormatResults(results, "text") // or "json", "xml"
|
||||
results, err := QueryWith(ctx, store, embedder, question, collectionName string, topK int)
|
||||
context, err := QueryContextWith(ctx, store, embedder, question, collectionName string, topK int)
|
||||
|
||||
// Format results
|
||||
FormatResultsText(results) // plain text
|
||||
FormatResultsContext(results) // XML for LLM injection
|
||||
FormatResultsJSON(results) // JSON array
|
||||
```
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- UK English
|
||||
- Tests: testify assert/require
|
||||
- Conventional commits
|
||||
- UK English (colour, organisation, initialise, behaviour)
|
||||
- Conventional commits: `type(scope): description`
|
||||
- Co-Author: `Co-Authored-By: Virgil <virgil@lethean.io>`
|
||||
- Licence: EUPL-1.2
|
||||
- Tests: testify assert/require
|
||||
- Integration tests: `//go:build rag` build tag
|
||||
- Mocks: `mockEmbedder` and `mockVectorStore` in `mock_test.go`
|
||||
|
||||
## Service Defaults
|
||||
|
||||
| Service | Host | Port | Notes |
|
||||
|---------|------|------|-------|
|
||||
| Qdrant | localhost | 6334 | gRPC |
|
||||
| Ollama | localhost | 11434 | HTTP |
|
||||
| Model | — | — | `nomic-embed-text` (768 dims) |
|
||||
|
|
|
|||
290
FINDINGS.md
290
FINDINGS.md
|
|
@ -1,290 +0,0 @@
|
|||
# FINDINGS.md — go-rag Research & Discovery
|
||||
|
||||
## 2026-02-19: Split from go-ai (Virgil)
|
||||
|
||||
### Origin
|
||||
|
||||
Extracted from `forge.lthn.ai/core/go-ai/rag/`. Zero internal go-ai dependencies.
|
||||
|
||||
### What Was Extracted
|
||||
|
||||
- 7 Go files (~1,017 LOC excluding tests)
|
||||
- 1 test file (chunk_test.go)
|
||||
|
||||
### Key Finding: Minimal Test Coverage
|
||||
|
||||
Only chunk.go has tests. The Qdrant and Ollama clients are untested — they depend on external services (Qdrant server, Ollama API) which makes unit testing harder. Consider mock interfaces.
|
||||
|
||||
### Consumers
|
||||
|
||||
- `go-ai/ai/rag.go` wraps this as `QueryRAGForTask()` facade
|
||||
- `go-ai/mcp/tools_rag.go` exposes RAG as MCP tools
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-19: Environment Review (Charon)
|
||||
|
||||
### go.mod Fix
|
||||
|
||||
Replace directive was `../core` — should be `../go`. Fixed. Tests now pass.
|
||||
|
||||
### Coverage
|
||||
|
||||
```
|
||||
go-rag: 18.4% coverage (only chunk.go tested)
|
||||
```
|
||||
|
||||
### Infrastructure Status
|
||||
|
||||
| Service | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| Qdrant | **Not running** | Need `docker run -d -p 6333:6333 -p 6334:6334 qdrant/qdrant` |
|
||||
| Ollama | **Not running locally** | M3 has Ollama at 10.69.69.108:11434, but local install preferred for tests |
|
||||
|
||||
### Testability Analysis
|
||||
|
||||
| File | Lines | Testable Without Services | Notes |
|
||||
|------|-------|---------------------------|-------|
|
||||
| chunk.go | 205 | Yes — pure functions | 8 tests exist, good coverage |
|
||||
| query.go | 163 | **Partially** — FormatResults* are pure | Query() needs Qdrant + Ollama |
|
||||
| qdrant.go | 226 | No — all gRPC calls | Need live Qdrant or mock interface |
|
||||
| ollama.go | 120 | **Partially** — EmbedDimension is pure | Embed() needs live Ollama |
|
||||
| ingest.go | 217 | No — orchestrates Qdrant + Ollama | Need mocks or live services |
|
||||
| helpers.go | 89 | **Partially** — QueryDocs/IngestDirectory are convenience wrappers | Same deps as query/ingest |
|
||||
|
||||
### Recommendation
|
||||
|
||||
Phase 1 should focus on pure-function tests (FormatResults*, EmbedDimension, defaults, valueToGo). Phase 2 extracts `Embedder` and `VectorStore` interfaces to enable mocked testing for ingest/query. Phase 3+ needs live services.
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-20: Phase 1 Pure-Function Tests Complete (go-rag agent)
|
||||
|
||||
### Coverage Improvement
|
||||
|
||||
```
|
||||
Before: 18.4% (8 tests in chunk_test.go only)
|
||||
After: 38.8% (66 tests across 4 test files)
|
||||
```
|
||||
|
||||
### Per-Function Coverage
|
||||
|
||||
All targeted pure functions now at 100% coverage:
|
||||
|
||||
| Function | File | Coverage |
|
||||
|----------|------|----------|
|
||||
| FormatResultsText | query.go | 100% |
|
||||
| FormatResultsContext | query.go | 100% |
|
||||
| FormatResultsJSON | query.go | 100% |
|
||||
| DefaultQueryConfig | query.go | 100% |
|
||||
| DefaultOllamaConfig | ollama.go | 100% |
|
||||
| DefaultQdrantConfig | qdrant.go | 100% |
|
||||
| DefaultChunkConfig | chunk.go | 100% |
|
||||
| DefaultIngestConfig | ingest.go | 100% |
|
||||
| EmbedDimension | ollama.go | 100% |
|
||||
| Model | ollama.go | 100% |
|
||||
| valueToGo | qdrant.go | 100% |
|
||||
| ChunkID | chunk.go | 100% |
|
||||
| ChunkMarkdown | chunk.go | 97.6% |
|
||||
| pointIDToString | qdrant.go | 83.3% |
|
||||
|
||||
### Discoveries
|
||||
|
||||
1. **OllamaClient can be constructed with nil `client` field** for testing pure methods (EmbedDimension, Model). The struct fields are unexported but accessible within the same package.
|
||||
|
||||
2. **Qdrant protobuf constructors** (`NewValueString`, `NewValueInt`, etc.) make it straightforward to build test values for `valueToGo` without needing a live Qdrant connection.
|
||||
|
||||
3. **pointIDToString default branch** (83.3%) — the uncovered path is a `PointId` with `PointIdOptions` set to an unknown type. This cannot be constructed via the public API (`NewIDNum` and `NewIDUUID` are the only constructors), so the 83.3% is the practical maximum without reflection hacks.
|
||||
|
||||
4. **FormatResultsJSON output is valid JSON** — confirmed by round-tripping through `json.Unmarshal` in tests. The hand-crafted JSON builder in `query.go` correctly handles escaping of special characters.
|
||||
|
||||
5. **ChunkMarkdown rune safety** — the overlap logic in `chunk.go` correctly uses `[]rune` slicing, confirmed by CJK text tests that would corrupt if byte-level slicing were used.
|
||||
|
||||
6. **Remaining 61.2% untested** is entirely in functions that require live Qdrant or Ollama: `NewQdrantClient`, `Search`, `UpsertPoints`, `Embed`, `EmbedBatch`, `Ingest`, `IngestFile`, and the helper wrappers. These are Phase 2 (mock interfaces) and Phase 3 (integration) targets.
|
||||
|
||||
### Test Files Created
|
||||
|
||||
| File | Tests | What It Covers |
|
||||
|------|-------|----------------|
|
||||
| query_test.go | 18 | FormatResultsText, FormatResultsContext, FormatResultsJSON, DefaultQueryConfig |
|
||||
| ollama_test.go | 8 | DefaultOllamaConfig, EmbedDimension (5 models), Model |
|
||||
| qdrant_test.go | 24 | DefaultQdrantConfig, pointIDToString, valueToGo (all types + nesting), Point, SearchResult |
|
||||
| chunk_test.go (extended) | 16 new | Empty input, headers-only, unicode/emoji, long paragraphs, config boundaries, ChunkID edge cases, DefaultChunkConfig, DefaultIngestConfig |
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-20: Phase 2 Test Infrastructure Complete (go-rag agent)
|
||||
|
||||
### Coverage Improvement
|
||||
|
||||
```
|
||||
Before: 38.8% (66 tests across 4 test files)
|
||||
After: 69.0% (135 leaf-level tests across 7 test files)
|
||||
```
|
||||
|
||||
### Interface Extraction
|
||||
|
||||
Two interfaces extracted to decouple business logic from external services:
|
||||
|
||||
| Interface | File | Methods | Satisfied By |
|
||||
|-----------|------|---------|--------------|
|
||||
| `Embedder` | embedder.go | Embed, EmbedBatch, EmbedDimension | `*OllamaClient` |
|
||||
| `VectorStore` | vectorstore.go | CreateCollection, CollectionExists, DeleteCollection, UpsertPoints, Search | `*QdrantClient` |
|
||||
|
||||
### Signature Changes
|
||||
|
||||
The following functions now accept interfaces instead of concrete types:
|
||||
|
||||
| Function | Old Signature | New Signature |
|
||||
|----------|--------------|---------------|
|
||||
| `Ingest` | `*QdrantClient, *OllamaClient` | `VectorStore, Embedder` |
|
||||
| `IngestFile` | `*QdrantClient, *OllamaClient` | `VectorStore, Embedder` |
|
||||
| `Query` | `*QdrantClient, *OllamaClient` | `VectorStore, Embedder` |
|
||||
|
||||
These changes are backwards-compatible because `*QdrantClient` satisfies `VectorStore` and `*OllamaClient` satisfies `Embedder`.
|
||||
|
||||
### New Helper Functions
|
||||
|
||||
Added interface-accepting helpers that the convenience wrappers now delegate to:
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `QueryWith` | Query with provided store + embedder |
|
||||
| `QueryContextWith` | Query + format as context XML |
|
||||
| `IngestDirWith` | Ingest directory with provided store + embedder |
|
||||
| `IngestFileWith` | Ingest single file with provided store + embedder |
|
||||
|
||||
### Per-Function Coverage (Phase 2 targets)
|
||||
|
||||
| Function | File | Coverage | Notes |
|
||||
|----------|------|----------|-------|
|
||||
| Ingest | ingest.go | 86.8% | Uncovered: filepath.Rel error branch |
|
||||
| IngestFile | ingest.go | 100% | |
|
||||
| Query | query.go | 100% | |
|
||||
| QueryWith | helpers.go | 100% | |
|
||||
| QueryContextWith | helpers.go | 100% | |
|
||||
| IngestDirWith | helpers.go | 100% | |
|
||||
| IngestFileWith | helpers.go | 100% | |
|
||||
|
||||
### Discoveries
|
||||
|
||||
1. **Interface method signatures must match exactly** -- `EmbedDimension()` returns `uint64` (not `int`), and `Search` takes `limit uint64` and `filter map[string]string` (not `limit int, threshold float32`). The task description suggested approximate signatures; the actual code was the source of truth.
|
||||
|
||||
2. **Convenience wrappers cannot be mocked** -- `QueryDocs`, `IngestDirectory`, `IngestSingleFile` construct their own concrete clients internally. Added `*With` variants that accept interfaces for testability. The convenience wrappers now delegate to these.
|
||||
|
||||
3. **ChunkMarkdown preserves section headers in chunk text** -- Small sections that fit within the chunk size include the `## Header` line in the chunk text. Tests must use `Contains` rather than `Equal` when checking chunk text.
|
||||
|
||||
4. **Mock vector store score calculation** -- The mock assigns scores as `1.0 - index*0.1`, so the second stored point gets 0.9. Tests using threshold must account for this.
|
||||
|
||||
5. **Remaining 31% untested** is entirely in concrete client implementations (QdrantClient methods, OllamaClient.Embed/EmbedBatch, NewOllamaClient, NewQdrantClient) and the convenience wrapper functions that construct live clients. These are Phase 3 (integration test with live services) targets.
|
||||
|
||||
### Test Files Created/Modified
|
||||
|
||||
| File | New Tests | What It Covers |
|
||||
|------|-----------|----------------|
|
||||
| mock_test.go | -- | mockEmbedder + mockVectorStore implementations |
|
||||
| ingest_test.go (new) | 23 | Ingest (17 subtests) + IngestFile (6 subtests) with mocks |
|
||||
| query_test.go (extended) | 12 | Query function with mocks: embedding, search, threshold, errors, payload extraction |
|
||||
| helpers_test.go (new) | 16 | QueryWith (4), QueryContextWith (3), IngestDirWith (4), IngestFileWith (5) |
|
||||
|
||||
### New Source Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| embedder.go | `Embedder` interface definition |
|
||||
| vectorstore.go | `VectorStore` interface definition |
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-20: Phase 3 Integration Tests with Live Services (go-rag agent)
|
||||
|
||||
### Coverage Improvement
|
||||
|
||||
```
|
||||
Before: 69.0% (135 tests across 7 test files, mock-based only)
|
||||
After: 89.2% (204 tests across 10 test files, includes live Qdrant + Ollama)
|
||||
```
|
||||
|
||||
### Infrastructure Verified
|
||||
|
||||
| Service | Version | Status | Connection |
|
||||
|---------|---------|--------|------------|
|
||||
| Qdrant | 1.16.3 | Running (Docker) | gRPC localhost:6334, REST localhost:6333 |
|
||||
| Ollama | native + ROCm | Running | HTTP localhost:11434, model: nomic-embed-text (F16, 274MB) |
|
||||
|
||||
### Discoveries
|
||||
|
||||
1. **Qdrant point IDs must be valid UUIDs** -- `qdrant.NewID()` wraps the string as a UUID field. Qdrant's server-side UUID parser accepts 32-character hex strings (as produced by `ChunkID` via MD5) but rejects arbitrary strings like `point-alpha`. Error: `Unable to parse UUID: point-alpha`. Integration tests must use `ChunkID()` or MD5 hex format for point IDs.
|
||||
|
||||
2. **Qdrant Go client version warning is benign** -- The client library (v1.16.2) logs `WARN Unable to compare versions` and `Client version is not compatible with server version` when connecting to Qdrant v1.16.3. This is a cosmetic mismatch in version parsing — all operations function correctly despite the warning.
|
||||
|
||||
3. **Qdrant indexing latency** -- After upserting points, a 500ms sleep is needed before searching to avoid flaky results. For small datasets the indexing is nearly instant, but the sleep provides a safety margin on slower machines.
|
||||
|
||||
4. **Ollama embedding determinism** -- Embedding the same text twice with `nomic-embed-text` produces bit-identical vectors (`float32` level). This is important for idempotent ingest operations.
|
||||
|
||||
5. **Ollama accepts empty strings** -- `Embed(ctx, "")` returns a valid 768-dimension vector without error. This is Ollama-specific behaviour and may differ with other embedding providers.
|
||||
|
||||
6. **Semantic similarity works as expected** -- When ingesting both programming and cooking documents, a query about "Go functions and closures" correctly ranks the programming document highest. The cosine distance metric in Qdrant combined with nomic-embed-text embeddings provides meaningful semantic differentiation.
|
||||
|
||||
7. **Convenience wrappers (QueryDocs, IngestDirectory) create their own gRPC connections** -- Each call to `QueryDocs` or `IngestDirectory` establishes a new Qdrant gRPC connection. In production this is fine for CLI commands, but for high-throughput scenarios the `*With` variants that accept pre-created clients should be preferred.
|
||||
|
||||
8. **Remaining ~11% untested** -- The uncovered code is primarily error-handling branches in `NewQdrantClient` (connection failure), `Close()`, and the `filepath.Rel` error branch in `Ingest`. These represent defensive code paths that are difficult to trigger in normal operation.
|
||||
|
||||
### Test Files Created
|
||||
|
||||
| File | Tests | What It Covers |
|
||||
|------|-------|----------------|
|
||||
| qdrant_integration_test.go | 11 | Health check, create/delete/list/info collection, exists check, upsert+search, filter, empty upsert, ID validation, overwrite |
|
||||
| ollama_integration_test.go | 9 | Verify model, embed single, embed batch, consistency, dimension match, model name, different texts, non-zero values, empty string |
|
||||
| integration_test.go | 12 | End-to-end ingest+query, format results, IngestFile, QueryWith, QueryContextWith, IngestDirWith, IngestFileWith, QueryDocs, IngestDirectory, recreate flag, semantic similarity |
|
||||
|
||||
### Build Tag Strategy
|
||||
|
||||
All integration tests use `//go:build rag` to isolate them from CI runs that lack live services:
|
||||
|
||||
```bash
|
||||
go test ./... -count=1 # 135 tests, 69.0% — mock-only, no services needed
|
||||
go test -tags rag ./... -count=1 # 204 tests, 89.2% — requires Qdrant + Ollama
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-20: Phase 4 GPU Benchmarks (Charon)
|
||||
|
||||
### Hardware
|
||||
|
||||
- **CPU**: AMD Ryzen 9 9950X (32 threads @ 5.7GHz)
|
||||
- **GPU**: AMD Radeon RX 7800 XT (ROCm, gfx1100)
|
||||
- **Ollama**: Native with ROCm, nomic-embed-text (F16, 137M params)
|
||||
- **Qdrant**: v1.16.3 (Docker, localhost)
|
||||
|
||||
### Benchmark Results
|
||||
|
||||
| Operation | Latency | Throughput | Notes |
|
||||
|-----------|---------|------------|-------|
|
||||
| Single embed | 10.3ms | 97/sec | nomic-embed-text via Ollama ROCm |
|
||||
| Batch embed (10 texts) | 102ms | 98/sec effective | Sequential calls, no batch API |
|
||||
| Embed 50 chars | ~10ms | — | Text length has negligible impact |
|
||||
| Embed 2000 chars | ~10ms | — | Tokeniser dominates, not GPU |
|
||||
| Qdrant search (100 pts) | 111µs | 9,042 QPS | Cosine similarity, top-5 |
|
||||
| Qdrant search (200 pts) | 152µs | 6,580 QPS | Cosine similarity, top-5 |
|
||||
| Chunk 50 sections | 11.2µs | 89K/sec | Pure CPU, no I/O |
|
||||
| Chunk 1000 paragraphs | 107µs | 9.4K/sec | Scales linearly |
|
||||
|
||||
### Key Findings
|
||||
|
||||
1. **EmbedBatch is sequential** — `EmbedBatch` calls `Embed` in a loop. Ollama's `/api/embed` endpoint accepts a single `input` string. There is no batch API at the HTTP level — each text requires a separate request. Batch throughput equals single throughput.
|
||||
|
||||
2. **Text length barely affects latency** — 50-character and 2000-character texts both embed in ~10ms. The tokeniser and model forward pass dominate; HTTP overhead is negligible on localhost.
|
||||
|
||||
3. **Qdrant search is sub-millisecond** — Even with 200 points, search takes 152µs. The bottleneck in any RAG pipeline will be embedding, not search.
|
||||
|
||||
4. **Pipeline bottleneck is embedding** — A full ingest+query cycle for 5 documents takes ~1.5s, with ~95% of that time in embedding calls. Optimisation efforts should focus on reducing embedding round-trips.
|
||||
|
||||
5. **Ollama ROCm GPU utilisation** — The nomic-embed-text model (137M params, F16) fits easily in 16GB VRAM. GPU utilisation during embedding is brief (~2ms compute per call) — the remaining ~8ms is HTTP + serialisation overhead.
|
||||
|
||||
### Files Created
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| benchmark_test.go | Go benchmarks + throughput tests (build tag: rag) |
|
||||
102
TODO.md
102
TODO.md
|
|
@ -1,102 +0,0 @@
|
|||
# TODO.md — go-rag Task Queue
|
||||
|
||||
Dispatched from core/go orchestration. Pick up tasks in phase order.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Environment Setup
|
||||
|
||||
- [x] **Fix go.mod replace directive** — Was `../core`, corrected to `../go`. (Charon, 19 Feb 2026)
|
||||
- [x] **Run Qdrant locally** — Docker on localhost:6333/6334, v1.16.3. (Charon, 19 Feb 2026)
|
||||
- [x] **Install Ollama** — Native with ROCm on snider-linux. Model: nomic-embed-text (F16). (Charon, 19 Feb 2026)
|
||||
- [x] **Verify both services** — Integration tests pass: 32 tests across qdrant/ollama/full pipeline. (Charon, 20 Feb 2026)
|
||||
|
||||
## Phase 1: Unit Tests (18.4% -> 38.8% coverage)
|
||||
|
||||
All pure-function tests complete. Remaining untested functions require live services (Phase 2/3).
|
||||
|
||||
### Testable Without External Services
|
||||
|
||||
- [x] **FormatResults tests** — FormatResultsText, FormatResultsContext, FormatResultsJSON with known QueryResult inputs. Pure string formatting, no deps. (acb987a)
|
||||
- [x] **DefaultConfig tests** — Verify DefaultQdrantConfig, DefaultOllamaConfig, DefaultQueryConfig, DefaultChunkConfig, DefaultIngestConfig return expected values. (acb987a)
|
||||
- [x] **EmbedDimension tests** — OllamaClient.EmbedDimension() for each model name (nomic-embed-text=768, mxbai-embed-large=1024, all-minilm=384, unknown=768). (acb987a)
|
||||
- [x] **Point/SearchResult types** — Round-trip tests for Point struct and pointIDToString helper. (acb987a)
|
||||
- [x] **valueToGo tests** — Qdrant value conversion for string, int, double, bool, list, struct, nil. (acb987a)
|
||||
- [x] **Additional chunk tests** — Empty input, only headers no content, unicode/emoji, very long paragraph. (acb987a)
|
||||
|
||||
### Require External Services (use build tag `//go:build rag`)
|
||||
|
||||
- [x] **Qdrant client tests** — Create collection, upsert, search, delete, list, info, filter, overwrite. Skip if Qdrant unavailable. 11 subtests in `qdrant_integration_test.go`. (e90f281)
|
||||
- [x] **Ollama client tests** — Embed single text, embed batch, verify model, consistency, dimension check, different texts, non-zero values, empty string. 9 subtests in `ollama_integration_test.go`. (e90f281)
|
||||
- [x] **Full pipeline integration test** — Ingest directory, query, format results, all helpers (QueryWith, QueryContextWith, IngestDirWith, IngestFileWith, QueryDocs, IngestDirectory), recreate flag, semantic similarity. 12 subtests in `integration_test.go`. (e90f281)
|
||||
|
||||
## Phase 2: Test Infrastructure (38.8% -> 69.0% coverage)
|
||||
|
||||
- [x] **Interface extraction** — Extracted `Embedder` interface (embedder.go) and `VectorStore` interface (vectorstore.go). Updated `Ingest`, `IngestFile`, `Query` to accept interfaces. Added `QueryWith`, `QueryContextWith`, `IngestDirWith`, `IngestFileWith` helpers. (a49761b)
|
||||
- [x] **Mock embedder** — Returns deterministic 0.1 vectors, tracks all calls, supports error injection and custom embed functions. (a49761b)
|
||||
- [x] **Mock vector store** — In-memory map, stores points, returns them on search with fake descending scores, supports filtering, tracks all calls. (a49761b)
|
||||
- [x] **Re-test with mocks** — 69 new mock-based tests across ingest (23), query (12), and helpers (16). Coverage from 38.8% to 69.0%. (a49761b)
|
||||
|
||||
## Phase 3: Enhancements
|
||||
|
||||
All tasks are pure Go, testable with existing mocks. No external services needed.
|
||||
|
||||
### 3.1 Chunk Boundary Improvements
|
||||
|
||||
- [x] **Sentence-aware splitting** — When a paragraph exceeds `ChunkConfig.Size`, split at sentence boundaries (`. `, `? `, `! `) instead of adding the whole paragraph as an oversized chunk. Keep current behaviour as fallback when no sentence boundaries exist. (cf26e88)
|
||||
- [x] **Overlap boundary alignment** — Current overlap slices by rune count from the end of the previous chunk. Improve by aligning overlap to word boundaries (find the nearest space before the overlap point) to avoid splitting mid-word. (cf26e88)
|
||||
- [x] **Tests** — (a) Sentence splitting with 3 sentences > Size, (b) overlap word boundary alignment, (c) existing tests still pass (no regression). (cf26e88)
|
||||
|
||||
### 3.2 Collection Management Helpers
|
||||
|
||||
- [x] **Create `collections.go`** — Helper functions for collection lifecycle:
|
||||
- `ListCollections(ctx, store VectorStore) ([]string, error)` — wraps store method
|
||||
- `DeleteCollection(ctx, store VectorStore, name string) error` — wraps store method
|
||||
- `CollectionStats(ctx, store VectorStore, name string) (*CollectionInfo, error)` — point count, vector size, status. Needs `CollectionInfo` struct (not Qdrant-specific). (cf26e88)
|
||||
- [x] **Add `ListCollections` and `DeleteCollection` to VectorStore interface** — Currently these methods exist on `QdrantClient` but NOT on the `VectorStore` interface. Add them and update mock. (cf26e88)
|
||||
- [x] **Tests** — Mock-based tests for all helpers, error injection. (cf26e88)
|
||||
|
||||
### 3.3 Keyword Pre-Filter
|
||||
|
||||
- [x] **Create `keyword.go`** — `KeywordFilter(results []QueryResult, keywords []string) []QueryResult` — re-ranks results by boosting scores for results containing query keywords. Pure string matching (case-insensitive `strings.Contains`).
|
||||
- Boost formula: `score *= 1.0 + 0.1 * matchCount` (each keyword match adds 10% boost)
|
||||
- Re-sort by boosted score descending (cf26e88)
|
||||
- [x] **Add `Keywords bool` to QueryConfig** — When true, extract keywords from query text and apply KeywordFilter after vector search. (cf26e88)
|
||||
- [x] **Tests** — (a) No keywords (passthrough), (b) single keyword boost, (c) multiple keywords, (d) case insensitive, (e) no matches (scores unchanged). (cf26e88)
|
||||
|
||||
### 3.4 Benchmarks
|
||||
|
||||
- [x] **Create `benchmark_test.go`** — No build tag (mock-only):
|
||||
- `BenchmarkChunk` — 10KB markdown document, default config
|
||||
- `BenchmarkChunkWithOverlap` — Same document, overlap=100
|
||||
- `BenchmarkQuery_Mock` — Query with mock embedder + mock store
|
||||
- `BenchmarkIngest_Mock` — Ingest 10 files with mock embedder + mock store
|
||||
- `BenchmarkFormatResults` — FormatResultsText/Context/JSON with 20 results
|
||||
- `BenchmarkKeywordFilter` — 100 results, 5 keywords (cf26e88)
|
||||
|
||||
## Phase 4: GPU Embeddings — COMPLETE
|
||||
|
||||
- [x] **ROCm Ollama** — Tested on RX 7800 XT. 97 embeds/sec single, 10.3ms latency. See FINDINGS.md. (Charon, 20 Feb 2026)
|
||||
- [x] **Batch optimisation** — Investigated: Ollama has no batch API. EmbedBatch is inherently sequential (one HTTP call per text). No optimisation possible without upstream changes. (Charon, 20 Feb 2026)
|
||||
- [x] **Benchmarks** — Go benchmarks added: BenchmarkEmbedSingle, BenchmarkEmbedBatch, BenchmarkEmbedVaryingLength, BenchmarkChunkMarkdown, BenchmarkQdrantSearch, BenchmarkFullPipeline + throughput/latency tests. (Charon, 20 Feb 2026)
|
||||
|
||||
---
|
||||
|
||||
## Known Issues
|
||||
|
||||
1. ~~**go.mod had wrong replace path**~~ — Fixed by Charon.
|
||||
2. ~~**Qdrant and Ollama not running on snider-linux**~~ — **Resolved.** Qdrant v1.16.3 (Docker) and Ollama with ROCm + nomic-embed-text now running on localhost.
|
||||
3. ~~**No mocks/interfaces**~~ — **Resolved in Phase 2.** `Embedder` and `VectorStore` interfaces extracted; mock implementations in `mock_test.go`.
|
||||
4. **`log.E` returns error** — `forge.lthn.ai/core/go/pkg/log.E` wraps errors with component context. This is the framework's logging pattern.
|
||||
|
||||
## Platform
|
||||
|
||||
- **OS**: Ubuntu (linux/amd64) — snider-linux
|
||||
- **Co-located with**: go-rocm, go-p2p
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Charon dispatches tasks here after review
|
||||
2. Pick up tasks in phase order
|
||||
3. Mark `[x]` when done, note commit hash
|
||||
4. New discoveries → add notes, flag in FINDINGS.md
|
||||
251
docs/architecture.md
Normal file
251
docs/architecture.md
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
# go-rag Architecture
|
||||
|
||||
Module: `forge.lthn.ai/core/go-rag`
|
||||
|
||||
## Overview
|
||||
|
||||
go-rag is a Retrieval-Augmented Generation library for Go. It provides document chunking, embedding generation via Ollama, vector storage and search via Qdrant, and formatted context retrieval suitable for injection into LLM prompts. The library is designed around two core interfaces — `Embedder` and `VectorStore` — that decouple business logic from external service implementations.
|
||||
|
||||
## Package Layout
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `embedder.go` | `Embedder` interface definition |
|
||||
| `vectorstore.go` | `VectorStore` interface + `CollectionInfo` struct |
|
||||
| `chunk.go` | Markdown chunking — sections, paragraphs, sentences, overlap |
|
||||
| `ollama.go` | `OllamaClient` — implements `Embedder` via Ollama HTTP API |
|
||||
| `qdrant.go` | `QdrantClient` — implements `VectorStore` via Qdrant gRPC |
|
||||
| `ingest.go` | Ingestion pipeline — read files, chunk, embed, batch upsert |
|
||||
| `query.go` | Query pipeline — embed query, search, threshold filter, format results |
|
||||
| `keyword.go` | Keyword boosting post-filter for re-ranking search results |
|
||||
| `collections.go` | Package-level collection management helpers |
|
||||
| `helpers.go` | Convenience wrappers — `*With` variants and default-client functions |
|
||||
|
||||
## Core Interfaces
|
||||
|
||||
### Embedder
|
||||
|
||||
```go
|
||||
type Embedder interface {
|
||||
Embed(ctx context.Context, text string) ([]float32, error)
|
||||
EmbedBatch(ctx context.Context, texts []string) ([][]float32, error)
|
||||
EmbedDimension() uint64
|
||||
}
|
||||
```
|
||||
|
||||
`OllamaClient` satisfies this interface. The interface enables mock-based testing without a live Ollama instance.
|
||||
|
||||
### VectorStore
|
||||
|
||||
```go
|
||||
type VectorStore interface {
|
||||
CreateCollection(ctx context.Context, name string, vectorSize uint64) error
|
||||
CollectionExists(ctx context.Context, name string) (bool, error)
|
||||
DeleteCollection(ctx context.Context, name string) error
|
||||
ListCollections(ctx context.Context) ([]string, error)
|
||||
CollectionInfo(ctx context.Context, name string) (*CollectionInfo, error)
|
||||
UpsertPoints(ctx context.Context, collection string, points []Point) error
|
||||
Search(ctx context.Context, collection string, vector []float32, limit uint64, filter map[string]string) ([]SearchResult, error)
|
||||
}
|
||||
```
|
||||
|
||||
`QdrantClient` satisfies this interface. `CollectionInfo` is backend-agnostic (name, point count, vector size, status string).
|
||||
|
||||
## Qdrant Client
|
||||
|
||||
`QdrantClient` wraps the official `github.com/qdrant/go-client` gRPC library.
|
||||
|
||||
**Connection**: gRPC on port 6334 (default). Supports TLS and API key authentication.
|
||||
|
||||
**Collection creation**: Uses cosine distance metric (`qdrant.Distance_Cosine`). Vector dimensionality is derived from the configured embedding model via `Embedder.EmbedDimension()`.
|
||||
|
||||
**Point IDs**: Qdrant requires valid UUIDs. Point IDs are generated by `ChunkID()` using MD5 of `"path:index:text_prefix"`, producing 32-character hex strings that Qdrant accepts as UUIDs.
|
||||
|
||||
**Search**: Uses Qdrant's `QueryPoints` API. Payload filters are expressed as `Must` conditions (logical AND). Results include the similarity score and full payload.
|
||||
|
||||
**Payload conversion**: Qdrant payloads are protobuf `Value` types. The `valueToGo` function converts these to native Go types (`string`, `int64`, `float64`, `bool`, `[]any`, `map[string]any`).
|
||||
|
||||
**Version mismatch**: The client library (v1.16.2) logs a benign warning when connecting to Qdrant v1.16.3. All operations function correctly.
|
||||
|
||||
## Ollama Embedding Client
|
||||
|
||||
`OllamaClient` wraps the `github.com/ollama/ollama/api` HTTP client.
|
||||
|
||||
**Connection**: HTTP on port 11434 (default), 30-second timeout.
|
||||
|
||||
**Embedding**: Calls `/api/embed`. The Ollama API returns `float64` values; these are converted to `float32` for Qdrant compatibility.
|
||||
|
||||
**Batch embedding**: `EmbedBatch` is sequential — it calls `Embed` in a loop. Ollama has no native batch API endpoint. Batch throughput equals single-embed throughput.
|
||||
|
||||
**Supported models and dimensions**:
|
||||
|
||||
| Model | Dimensions |
|
||||
|-------|-----------|
|
||||
| `nomic-embed-text` (default) | 768 |
|
||||
| `mxbai-embed-large` | 1024 |
|
||||
| `all-minilm` | 384 |
|
||||
| (unknown) | 768 (fallback) |
|
||||
|
||||
**Determinism**: `nomic-embed-text` produces bit-identical `float32` vectors for identical input text, which makes ingest operations idempotent.
|
||||
|
||||
**Empty strings**: Ollama accepts empty string input and returns a valid zero-padded vector without error. This behaviour is Ollama-specific.
|
||||
|
||||
## Markdown Chunking
|
||||
|
||||
`ChunkMarkdown(text string, cfg ChunkConfig) []Chunk` is the primary chunking function.
|
||||
|
||||
**ChunkConfig**:
|
||||
|
||||
```go
|
||||
type ChunkConfig struct {
|
||||
Size int // Target characters per chunk (default 500)
|
||||
Overlap int // Overlap in runes between adjacent chunks (default 50)
|
||||
}
|
||||
```
|
||||
|
||||
**Three-level splitting strategy**:
|
||||
|
||||
1. **Section split** — Text is first split at `## ` header boundaries. The header line is preserved with its section content.
|
||||
|
||||
2. **Paragraph split** — Sections larger than `Size` are split at double-newline paragraph boundaries. Multiple consecutive newlines are normalised to double-newlines.
|
||||
|
||||
3. **Sentence split** — Paragraphs that individually exceed `Size` are split at sentence boundaries (`. `, `? `, `! `). Sentence splitting is applied before paragraph accumulation to avoid oversized chunks. When no sentence boundaries exist, the oversized paragraph is added as-is.
|
||||
|
||||
**Overlap**: When a chunk boundary is crossed, the new chunk begins with the trailing `Overlap` runes of the previous chunk. The overlap start point is aligned to the nearest word boundary (first space within the overlap slice) to avoid splitting mid-word. Overlap is rune-safe; UTF-8 multi-byte characters are handled correctly.
|
||||
|
||||
**Chunk identity**: Each `Chunk` struct carries `Text`, `Section` (the `## ` header title), and `Index` (zero-based global counter across all sections in the document).
|
||||
|
||||
**ChunkID**: Deterministic MD5 hash of `"path:index:text_prefix"` (first 100 runes of text). Used as the Qdrant point ID.
|
||||
|
||||
**Category detection**: `Category(path string) string` classifies files by path keywords into categories: `ui-component`, `brand`, `product-brief`, `help-doc`, `task`, `architecture`, `documentation`. Used as a payload field to enable category-scoped queries.
|
||||
|
||||
**Accepted file types**: `.md`, `.markdown`, `.txt` (checked by `ShouldProcess`).
|
||||
|
||||
## Ingestion Pipeline
|
||||
|
||||
`Ingest` and `IngestFile` accept `VectorStore` and `Embedder` interfaces.
|
||||
|
||||
**Directory ingestion** (`Ingest`):
|
||||
|
||||
1. Resolve and validate the source directory.
|
||||
2. Check whether the target collection exists. If `Recreate` is set and the collection exists, delete it first.
|
||||
3. Create the collection if it does not exist, using `embedder.EmbedDimension()` for vector size.
|
||||
4. Walk the directory recursively, collecting files matching `ShouldProcess`.
|
||||
5. For each file: read content, call `ChunkMarkdown`, embed each chunk, build `Point` structs with payload fields (`text`, `source`, `section`, `category`, `chunk_index`).
|
||||
6. Batch-upsert points to the vector store in slices of `BatchSize` (default 100).
|
||||
|
||||
**Point payload schema**:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `text` | string | Raw chunk text |
|
||||
| `source` | string | Relative file path from the ingestion directory root |
|
||||
| `section` | string | Markdown section header (may be empty) |
|
||||
| `category` | string | Category from `Category()` path detection |
|
||||
| `chunk_index` | int | Chunk position within the document |
|
||||
|
||||
## Query Pipeline
|
||||
|
||||
`Query(ctx, store, embedder, query string, cfg QueryConfig) ([]QueryResult, error)`:
|
||||
|
||||
1. Embed the query text using `embedder.Embed`.
|
||||
2. Construct a payload filter from `cfg.Category` if set.
|
||||
3. Call `store.Search` with the query vector, limit, and filter.
|
||||
4. Discard results below `cfg.Threshold` (default 0.5).
|
||||
5. Deserialise payload fields into `QueryResult` structs. `chunk_index` handles `int64`, `float64`, and `int` types to accommodate JSON unmarshalling differences.
|
||||
6. Optionally apply keyword boosting (`cfg.Keywords == true`).
|
||||
|
||||
**QueryConfig**:
|
||||
|
||||
```go
|
||||
type QueryConfig struct {
|
||||
Collection string
|
||||
Limit uint64 // Default 5
|
||||
Threshold float32 // Default 0.5
|
||||
Category string // Payload filter; empty means no filter
|
||||
Keywords bool // Enable keyword boosting post-filter
|
||||
}
|
||||
```
|
||||
|
||||
## Keyword Boosting
|
||||
|
||||
`KeywordFilter(results []QueryResult, keywords []string) []QueryResult` re-ranks results after vector search.
|
||||
|
||||
**Algorithm**: For each result, count how many keywords appear (case-insensitive substring match) in the chunk text. Apply a 10% score boost per matching keyword: `score *= 1.0 + 0.1 * matchCount`. Re-sort by boosted score descending.
|
||||
|
||||
**Keyword extraction**: `extractKeywords` splits the query on whitespace and discards words shorter than 3 characters.
|
||||
|
||||
**When enabled**: `Query` calls `extractKeywords` on the query string and passes the result to `KeywordFilter` after the threshold filter has been applied.
|
||||
|
||||
## Result Formatting
|
||||
|
||||
Three output formats are available:
|
||||
|
||||
| Function | Format | Use case |
|
||||
|----------|--------|----------|
|
||||
| `FormatResultsText` | Plain text with score/source headers | Human-readable display |
|
||||
| `FormatResultsContext` | XML `<retrieved_context>` with `<document>` elements | LLM prompt injection |
|
||||
| `FormatResultsJSON` | Hand-crafted JSON array | Structured consumption |
|
||||
|
||||
`FormatResultsContext` applies `html.EscapeString` to all attribute values and text content to produce well-formed XML safe for embedding in prompts.
|
||||
|
||||
## Collection Management
|
||||
|
||||
Package-level helpers in `collections.go` delegate to `VectorStore`:
|
||||
|
||||
```go
|
||||
ListCollections(ctx, store VectorStore) ([]string, error)
|
||||
DeleteCollection(ctx, store VectorStore, name string) error
|
||||
CollectionStats(ctx, store VectorStore, name string) (*CollectionInfo, error)
|
||||
```
|
||||
|
||||
## Convenience Helpers
|
||||
|
||||
Two tiers of helpers are provided in `helpers.go`:
|
||||
|
||||
**Interface-accepting (`*With` variants)** — accept pre-constructed `VectorStore` and `Embedder`. Suitable for testing, long-lived processes, and high-throughput use:
|
||||
|
||||
```go
|
||||
QueryWith(ctx, store, embedder, question, collectionName string, topK int) ([]QueryResult, error)
|
||||
QueryContextWith(ctx, store, embedder, question, collectionName string, topK int) (string, error)
|
||||
IngestDirWith(ctx, store, embedder, directory, collectionName string, recreate bool) error
|
||||
IngestFileWith(ctx, store, embedder, filePath, collectionName string) (int, error)
|
||||
```
|
||||
|
||||
**Default-client wrappers** — construct new `QdrantClient` and `OllamaClient` on each call using `DefaultQdrantConfig` and `DefaultOllamaConfig`. Each call opens a new gRPC connection. Suitable for CLI commands and infrequent operations:
|
||||
|
||||
```go
|
||||
QueryDocs(ctx, question, collectionName string, topK int) ([]QueryResult, error)
|
||||
QueryDocsContext(ctx, question, collectionName string, topK int) (string, error)
|
||||
IngestDirectory(ctx, directory, collectionName string, recreate bool) error
|
||||
IngestSingleFile(ctx, filePath, collectionName string) (int, error)
|
||||
```
|
||||
|
||||
`IngestDirectory` and `IngestSingleFile` additionally run `HealthCheck` on Qdrant and `VerifyModel` on Ollama before proceeding.
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
Measured on AMD Ryzen 9 9950X + RX 7800 XT with ROCm, `nomic-embed-text` (F16):
|
||||
|
||||
| Operation | Latency | Throughput |
|
||||
|-----------|---------|------------|
|
||||
| Single embed | 10.3ms | 97/sec |
|
||||
| Batch embed (10 texts) | 102ms | 98/sec effective |
|
||||
| Qdrant search (100 points) | 111µs | 9,042 QPS |
|
||||
| Qdrant search (200 points) | 152µs | 6,580 QPS |
|
||||
| Chunk 50 sections | 11.2µs | 89K/sec |
|
||||
| Chunk 1000 paragraphs | 107µs | 9.4K/sec |
|
||||
|
||||
The embedding step dominates pipeline latency. In a full ingest+query cycle for 5 documents, approximately 95% of elapsed time is in embedding calls. Text length (50 to 2000 characters) has negligible effect on embedding latency because tokenisation and HTTP overhead dominate the GPU compute time (~2ms).
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
go-rag
|
||||
├── forge.lthn.ai/core/go (logging — pkg/log)
|
||||
├── github.com/ollama/ollama (embedding HTTP client)
|
||||
├── github.com/qdrant/go-client (vector DB gRPC client)
|
||||
└── github.com/stretchr/testify (test assertions)
|
||||
```
|
||||
|
||||
Transitive: `google.golang.org/grpc`, `google.golang.org/protobuf`, `github.com/google/uuid`.
|
||||
233
docs/development.md
Normal file
233
docs/development.md
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
# go-rag Development Guide
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Required Services
|
||||
|
||||
**Qdrant** — vector database, gRPC on port 6334:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name qdrant \
|
||||
-p 6333:6333 \
|
||||
-p 6334:6334 \
|
||||
qdrant/qdrant:v1.16.3
|
||||
```
|
||||
|
||||
REST is on 6334, gRPC on 6334. The library connects via gRPC only.
|
||||
|
||||
**Ollama** — embedding model server, HTTP on port 11434:
|
||||
|
||||
```bash
|
||||
# Install Ollama (see https://ollama.com for platform-specific instructions)
|
||||
ollama pull nomic-embed-text
|
||||
ollama serve
|
||||
```
|
||||
|
||||
For AMD GPU (ROCm) on Linux, install the ROCm-enabled Ollama binary from the Ollama releases page. The `nomic-embed-text` model (274MB F16) is the default and recommended model.
|
||||
|
||||
### Go Version
|
||||
|
||||
Go 1.25 or later. The module uses a Go workspace (`go.work`) at the repository root that includes the `forge.lthn.ai/core/go` dependency via a local `replace` directive:
|
||||
|
||||
```
|
||||
replace forge.lthn.ai/core/go => ../go
|
||||
```
|
||||
|
||||
Ensure `../go` (the `go-rag` sibling repository) is present and its `go.mod` is consistent.
|
||||
|
||||
## Build and Test
|
||||
|
||||
### Unit Tests (no external services required)
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Runs 135 tests covering all pure functions and mock-based integration. No Qdrant or Ollama instance needed.
|
||||
|
||||
### Integration Tests (require live Qdrant and Ollama)
|
||||
|
||||
```bash
|
||||
go test -tags rag ./...
|
||||
```
|
||||
|
||||
Runs the full test suite of 204 tests, including:
|
||||
|
||||
- `qdrant_integration_test.go` — 11 subtests (collection lifecycle, upsert, search, filter)
|
||||
- `ollama_integration_test.go` — 9 subtests (model verification, single/batch embed, determinism)
|
||||
- `integration_test.go` — 12 subtests (full pipeline, all helpers, semantic similarity)
|
||||
|
||||
### Running a Single Test
|
||||
|
||||
```bash
|
||||
go test -v -run TestChunkMarkdown ./...
|
||||
go test -v -tags rag -run TestIntegration_FullPipeline ./...
|
||||
```
|
||||
|
||||
### Benchmarks
|
||||
|
||||
```bash
|
||||
# Mock-only benchmarks (no services):
|
||||
go test -bench=. -benchmem ./...
|
||||
|
||||
# GPU/service benchmarks (requires Qdrant + Ollama):
|
||||
go test -tags rag -bench=. -benchmem ./...
|
||||
```
|
||||
|
||||
Key benchmarks: `BenchmarkChunk`, `BenchmarkChunkWithOverlap`, `BenchmarkQuery_Mock`, `BenchmarkIngest_Mock`, `BenchmarkFormatResults`, `BenchmarkKeywordFilter`, `BenchmarkEmbedSingle`, `BenchmarkEmbedBatch`, `BenchmarkQdrantSearch`, `BenchmarkFullPipeline`.
|
||||
|
||||
### Coverage
|
||||
|
||||
```bash
|
||||
# Mock-only coverage:
|
||||
go test -coverprofile=coverage.out ./...
|
||||
go tool cover -html=coverage.out
|
||||
|
||||
# Full coverage with services:
|
||||
go test -tags rag -coverprofile=coverage.out ./...
|
||||
```
|
||||
|
||||
Current coverage targets: 69.0% without services, 89.2% with live services.
|
||||
|
||||
## Test Patterns
|
||||
|
||||
### Build Tag Strategy
|
||||
|
||||
Tests requiring external services carry the `//go:build rag` build tag. This isolates them from CI environments that lack live services. All pure-function and mock-based tests have no build tag and run unconditionally.
|
||||
|
||||
```go
|
||||
//go:build rag
|
||||
|
||||
package rag
|
||||
```
|
||||
|
||||
### Mock Implementations
|
||||
|
||||
`mock_test.go` provides two in-package test doubles:
|
||||
|
||||
**`mockEmbedder`** — deterministic, all-0.1 vectors of configurable dimension. Supports error injection via `embedErr`/`batchErr` and custom behaviour via `embedFunc`. Tracks all `Embed` and `EmbedBatch` calls with a mutex for concurrent-safe counting.
|
||||
|
||||
**`mockVectorStore`** — in-memory map-backed store. Returns stored points on search with fake descending scores (`1.0, 0.9, 0.8, ...`). Supports per-method error injection and a custom `searchFunc` override. Tracks all method calls.
|
||||
|
||||
Constructors:
|
||||
|
||||
```go
|
||||
embedder := newMockEmbedder(768)
|
||||
store := newMockVectorStore()
|
||||
```
|
||||
|
||||
Error injection:
|
||||
|
||||
```go
|
||||
embedder.embedErr = errors.New("embed failed")
|
||||
store.upsertErr = errors.New("store unavailable")
|
||||
```
|
||||
|
||||
### Test Naming Convention
|
||||
|
||||
Tests use `_Good`, `_Bad`, `_Ugly` suffix semantics:
|
||||
|
||||
- `_Good` — happy path
|
||||
- `_Bad` — expected error conditions (invalid input, service errors)
|
||||
- `_Ugly` — panic or edge cases
|
||||
|
||||
Table-driven subtests are used for pure functions with many input variants (e.g., `valueToGo`, `EmbedDimension`, `FormatResults*`).
|
||||
|
||||
### Integration Test Patterns
|
||||
|
||||
Integration tests skip gracefully when services are unavailable. Qdrant integration tests call `HealthCheck` and skip if it fails:
|
||||
|
||||
```go
|
||||
if err := client.HealthCheck(ctx); err != nil {
|
||||
t.Skipf("Qdrant unavailable: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
**Indexing latency**: After upserting points to Qdrant, tests include a 500ms sleep before searching to avoid flaky results on slower machines.
|
||||
|
||||
**Point ID format**: Qdrant requires UUID-format point IDs. Use `ChunkID()` to generate IDs, or any 32-character lowercase hex string. Arbitrary strings (e.g., `"point-alpha"`) are rejected by Qdrant's UUID parser.
|
||||
|
||||
**Unique collection names**: Integration tests create collections with timestamped or randomised names and delete them in `t.Cleanup` to avoid cross-test interference.
|
||||
|
||||
## Coding Standards
|
||||
|
||||
### Language
|
||||
|
||||
UK English throughout — in comments, documentation, variable names, and error messages. Use: `colour`, `organisation`, `initialise`, `serialise`, `behaviour`, `recognised`. Do not use American spellings.
|
||||
|
||||
### Go Style
|
||||
|
||||
- `declare(strict_types=1)` equivalent: all functions have explicit parameter and return types.
|
||||
- Error messages use the `log.E("component.Method", "what failed", err)` pattern from `forge.lthn.ai/core/go/pkg/log`. This wraps errors with component context for structured logging.
|
||||
- No naked returns.
|
||||
- Exported types and functions have doc comments.
|
||||
- Internal helpers are unexported and documented with concise inline comments.
|
||||
|
||||
### Formatting
|
||||
|
||||
Standard `gofmt` / `goimports`. No additional linter configuration is defined; the project follows standard Go conventions.
|
||||
|
||||
### Licence
|
||||
|
||||
Every new Go source file must include the EUPL-1.2 licence header:
|
||||
|
||||
```go
|
||||
// Copyright (C) 2026 Host UK Ltd.
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
```
|
||||
|
||||
### Commits
|
||||
|
||||
Conventional commits format: `type(scope): description`
|
||||
|
||||
Common types: `feat`, `fix`, `test`, `refactor`, `docs`, `chore`.
|
||||
|
||||
Every commit must include the co-author trailer:
|
||||
|
||||
```
|
||||
Co-Authored-By: Virgil <virgil@lethean.io>
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
feat(chunk): add sentence-aware splitting for oversized paragraphs
|
||||
|
||||
When a paragraph exceeds ChunkConfig.Size, split at sentence boundaries
|
||||
(". ", "? ", "! ") rather than adding the whole paragraph as an
|
||||
oversized chunk. Falls back to the full paragraph when no sentence
|
||||
boundaries exist.
|
||||
|
||||
Co-Authored-By: Virgil <virgil@lethean.io>
|
||||
```
|
||||
|
||||
## Adding a New Embedding Provider
|
||||
|
||||
1. Create a new file, e.g., `openai.go`.
|
||||
2. Define a config struct and constructor.
|
||||
3. Implement the `Embedder` interface: `Embed`, `EmbedBatch`, `EmbedDimension`.
|
||||
4. Add a corresponding `openai_test.go` with pure-function tests (config defaults, dimension lookup).
|
||||
5. Add an `openai_integration_test.go` with the `//go:build rag` tag for live API tests.
|
||||
|
||||
## Adding a New Vector Backend
|
||||
|
||||
1. Create a new file, e.g., `weaviate.go`.
|
||||
2. Define a config struct and constructor.
|
||||
3. Implement all methods of the `VectorStore` interface.
|
||||
4. Ensure `CollectionInfo` maps backend status codes to the `green`/`yellow`/`red`/`unknown` strings.
|
||||
5. Add integration tests under the `rag` build tag.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
**Wrong replace path in go.mod**: The replace directive must point to `../go`, not `../core`. If `go test` reports module not found for `forge.lthn.ai/core/go`, verify the `replace` directive and that the sibling directory exists.
|
||||
|
||||
**Qdrant UUID requirement**: Do not pass arbitrary strings as point IDs. Always use `ChunkID()` or another MD5/UUID generator. Qdrant rejects non-UUID strings with `Unable to parse UUID: <value>`.
|
||||
|
||||
**EmbedBatch is sequential**: There is no batch endpoint in the Ollama API. `EmbedBatch` calls `Embed` in a loop. If throughput is critical, parallelise calls yourself with goroutines and limit concurrency to avoid overwhelming the Ollama process.
|
||||
|
||||
**Collection not created before upsert**: `Ingest` handles collection creation automatically. If calling `UpsertPoints` directly, call `CreateCollection` (or `CollectionExists` + conditional create) first.
|
||||
|
||||
**Score threshold**: The default threshold is 0.5. For short or ambiguous queries this may return zero results. Lower the threshold in `QueryConfig.Threshold` or set it to 0.0 to return all results above the `Limit`.
|
||||
|
||||
**Convenience wrappers open a new connection per call**: `QueryDocs`, `IngestDirectory`, and `IngestSingleFile` construct new `QdrantClient` instances (and new gRPC connections) on every invocation. Use the `*With` variants with pre-created clients for server processes or loops.
|
||||
143
docs/history.md
Normal file
143
docs/history.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# go-rag Project History
|
||||
|
||||
## Origin
|
||||
|
||||
go-rag was extracted from `forge.lthn.ai/core/go-ai` on 19 February 2026 by Virgil.
|
||||
|
||||
The source code lived in `go-ai/rag/` as a subsystem of the meta AI hub. It had zero internal dependencies on go-ai's other packages (model management, MCP tools, inference backends), making extraction straightforward. At the time of extraction the package comprised 7 Go files (~1,017 LOC excluding tests) and a single test file covering only `chunk.go`.
|
||||
|
||||
**go-ai consumers retained**:
|
||||
- `go-ai/ai/rag.go` — wraps go-rag as `QueryRAGForTask()`, a facade used by AI task runners.
|
||||
- `go-ai/mcp/tools_rag.go` — exposes go-rag operations as MCP tool handlers (`rag_query`, `rag_ingest`, `rag_collections`).
|
||||
|
||||
These consumers were updated to import `forge.lthn.ai/core/go-rag` instead of the internal path.
|
||||
|
||||
## Phase 0: Environment Setup (19 February 2026, Charon)
|
||||
|
||||
**Problem**: The `go.mod` replace directive pointed to `../core` (the root Go workspace module) rather than `../go` (the `go` sibling package providing logging). Tests failed to compile.
|
||||
|
||||
**Resolution**: Replace directive corrected to `../go`. Qdrant v1.16.3 started via Docker. Ollama with ROCm installed natively on snider-linux (AMD RX 7800 XT, gfx1100). Model `nomic-embed-text` (F16, 274MB) pulled. All 32 initial tests passed after the fix.
|
||||
|
||||
## Phase 1: Pure-Function Tests (19–20 February 2026)
|
||||
|
||||
**Commit**: `acb987a`
|
||||
|
||||
Coverage before: 18.4% (8 tests in `chunk_test.go` only).
|
||||
Coverage after: 38.8% (66 tests across 4 test files).
|
||||
|
||||
Targeted all functions that did not require live external services:
|
||||
|
||||
- `FormatResultsText`, `FormatResultsContext`, `FormatResultsJSON`
|
||||
- All `Default*Config` functions
|
||||
- `OllamaClient.EmbedDimension` (pure switch on model name)
|
||||
- `OllamaClient.Model`
|
||||
- `QdrantClient.valueToGo` (protobuf value conversion)
|
||||
- `ChunkID`, `ChunkMarkdown` (extended edge cases: empty input, unicode, headers-only, long paragraphs)
|
||||
- `pointIDToString`
|
||||
|
||||
**Discovery**: `OllamaClient` can be constructed with a nil `client` field for testing pure methods. The remaining ~61% untested code was entirely in functions requiring live services.
|
||||
|
||||
**Discovery**: `pointIDToString` has an unreachable default branch — Qdrant only exposes `NewIDNum` and `NewIDUUID` constructors, so the third `PointIdOptions` case cannot be constructed without reflection. Coverage of 83.3% is the practical maximum for that function.
|
||||
|
||||
## Phase 2: Interface Extraction and Mock Infrastructure (20 February 2026)
|
||||
|
||||
**Commit**: `a49761b`
|
||||
|
||||
Coverage before: 38.8%.
|
||||
Coverage after: 69.0% (135 tests across 7 test files).
|
||||
|
||||
Two interfaces were extracted to decouple business logic from concrete service clients:
|
||||
|
||||
- `Embedder` (embedder.go) — `Embed`, `EmbedBatch`, `EmbedDimension`
|
||||
- `VectorStore` (vectorstore.go) — `CreateCollection`, `CollectionExists`, `DeleteCollection`, `ListCollections`, `CollectionInfo`, `UpsertPoints`, `Search`
|
||||
|
||||
`Ingest`, `IngestFile`, and `Query` were updated to accept interfaces rather than concrete `*QdrantClient` and `*OllamaClient` parameters. These changes were backwards-compatible because the concrete types satisfy the interfaces.
|
||||
|
||||
`*With` helper variants were added (`QueryWith`, `QueryContextWith`, `IngestDirWith`, `IngestFileWith`) to accept pre-constructed interfaces. The existing convenience wrappers (`QueryDocs`, `IngestDirectory`, `IngestSingleFile`) were refactored to delegate to the `*With` variants.
|
||||
|
||||
`mock_test.go` was created containing `mockEmbedder` (deterministic 0.1 vectors, call tracking, error injection) and `mockVectorStore` (in-memory map, fake descending scores, filter support, call tracking).
|
||||
|
||||
69 new mock-based tests were written: 23 for `Ingest`/`IngestFile`, 12 for `Query`, 16 for helpers, plus updated chunk tests.
|
||||
|
||||
**Discovery**: Interface method signatures must match exactly. `EmbedDimension` returns `uint64` (not `int`), and `Search` takes `limit uint64` and `filter map[string]string`. The task specification suggested approximate signatures; the source was authoritative.
|
||||
|
||||
## Phase 3: Integration Tests with Live Services (20 February 2026)
|
||||
|
||||
**Commit**: `e90f281`
|
||||
|
||||
Coverage before: 69.0%.
|
||||
Coverage after: 89.2% (204 tests across 10 test files).
|
||||
|
||||
Three integration test files were added under the `//go:build rag` build tag:
|
||||
|
||||
- `qdrant_integration_test.go` — 11 subtests: health check, collection create/delete/list/info, exists check, upsert and search, payload filter, empty upsert, ID validation, overwrite behaviour.
|
||||
- `ollama_integration_test.go` — 9 subtests: model verification, single embed, batch embed, embedding determinism, dimension match, model name, different texts producing different vectors, non-zero values, empty string handling.
|
||||
- `integration_test.go` — 12 subtests: full ingest+query pipeline, format results in all three formats, `IngestFile`, `QueryWith`, `QueryContextWith`, `IngestDirWith`, `IngestFileWith`, `QueryDocs`, `IngestDirectory`, recreate flag, semantic similarity verification.
|
||||
|
||||
**Discovery**: Qdrant point IDs must be valid UUIDs. Arbitrary strings such as `"point-alpha"` are rejected. The `ChunkID` MD5 hex output (32 lowercase hex chars) is accepted by Qdrant's UUID parser.
|
||||
|
||||
**Discovery**: Qdrant indexing requires a brief delay (500ms sleep) between upsert and search in tests to avoid race conditions on slower hardware.
|
||||
|
||||
**Discovery**: Semantic similarity works as expected. Queries about Go programming rank programming documents above cooking documents. Cosine distance combined with `nomic-embed-text` provides meaningful semantic differentiation.
|
||||
|
||||
**Discovery**: `QueryDocs` and `IngestDirectory` open a new gRPC connection on each call. For high-throughput use the `*With` variants with a shared client are preferable.
|
||||
|
||||
## Phase 3: Enhancements (20 February 2026)
|
||||
|
||||
**Commit**: `cf26e88`
|
||||
|
||||
### Sentence-Aware Splitting
|
||||
|
||||
`ChunkMarkdown` was enhanced to split oversized paragraphs at sentence boundaries (`. `, `? `, `! `) rather than adding the whole paragraph as an oversized chunk. The original fallback (adding the paragraph as-is) is retained when no sentence boundaries exist.
|
||||
|
||||
### Word-Boundary Overlap Alignment
|
||||
|
||||
The overlap logic was improved to align the overlap start point to the nearest word boundary within the overlap slice, avoiding split words at chunk beginnings. Rune-safe slicing was retained.
|
||||
|
||||
### Collection Management Helpers
|
||||
|
||||
`collections.go` was created with three package-level helpers: `ListCollections`, `DeleteCollection`, `CollectionStats`. `ListCollections` and `DeleteCollection` were added to the `VectorStore` interface; `mockVectorStore` was updated accordingly.
|
||||
|
||||
### Keyword Boosting
|
||||
|
||||
`keyword.go` was created with `KeywordFilter` (score boosting: +10% per matching keyword) and `extractKeywords` (words of 3+ characters). `QueryConfig` gained a `Keywords bool` field; `Query` applies the filter when the field is true.
|
||||
|
||||
### Benchmarks
|
||||
|
||||
`benchmark_test.go` was created (no build tag, mock-only): `BenchmarkChunk`, `BenchmarkChunkWithOverlap`, `BenchmarkQuery_Mock`, `BenchmarkIngest_Mock`, `BenchmarkFormatResults` (all three formats), `BenchmarkKeywordFilter`.
|
||||
|
||||
## Phase 4: GPU Benchmarks (20 February 2026, Charon)
|
||||
|
||||
Service benchmarks were added in `benchmark_gpu_test.go` under the `//go:build rag` tag.
|
||||
|
||||
**Hardware**: AMD Ryzen 9 9950X, AMD RX 7800 XT (ROCm, gfx1100), Qdrant v1.16.3 (Docker).
|
||||
|
||||
**Key findings**:
|
||||
|
||||
- Single embed latency: 10.3ms (97/sec). Text length (50 to 2000 chars) has negligible effect.
|
||||
- Batch embed throughput equals single embed throughput — there is no batch API in Ollama.
|
||||
- Qdrant search: 111µs for 100 points (9,042 QPS), 152µs for 200 points.
|
||||
- Pipeline bottleneck: embedding accounts for ~95% of a full ingest+query cycle.
|
||||
- GPU compute per embed: ~2ms. The remaining ~8ms is HTTP and serialisation overhead on localhost.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
**No batch embedding API**: Ollama's `/api/embed` endpoint accepts a single `input` string. `EmbedBatch` is implemented as a sequential loop. Parallel embedding requires multiple concurrent HTTP clients, which is left to callers.
|
||||
|
||||
**Convenience wrappers open connections per call**: `QueryDocs`, `IngestDirectory`, and `IngestSingleFile` construct and close a new gRPC connection on every invocation. This is acceptable for CLI tooling but unsuitable for server processes or tight loops.
|
||||
|
||||
**Category detection is path-based**: `Category()` classifies files by keyword matching on the file path. This is a heuristic with no configuration mechanism. False positives and missed classifications are expected for non-standard directory structures.
|
||||
|
||||
**Qdrant version mismatch warning**: The `qdrant/go-client` v1.16.2 library logs `WARN Unable to compare versions` when connecting to Qdrant v1.16.3. The warning is cosmetic; all operations function correctly.
|
||||
|
||||
**`filepath.Rel` error branch**: The error branch in `Ingest` for `filepath.Rel` failures (incrementing `stats.Errors` and continuing) is not covered by tests. This path requires a filesystem or OS-level anomaly to trigger and is not reachable via normal input.
|
||||
|
||||
**EmbedDimension default fallback**: Unknown model names return 768 (the `nomic-embed-text` dimension). If a different model is configured and its dimension is unknown to the library, the collection will be created with an incorrect vector size, causing upsert failures at the Qdrant level.
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- **Concurrent embedding**: A configurable worker pool for parallel `Embed` calls during batch ingestion would reduce pipeline latency in proportion to the number of workers, up to Ollama's concurrency limit.
|
||||
- **Streaming results**: `Query` returns all results after the full search completes. A channel-based API could stream results as they pass the threshold filter.
|
||||
- **Additional backends**: The `VectorStore` interface is ready for alternative implementations (Weaviate, pgvector, Milvus). The `Embedder` interface supports OpenAI, Cohere, or any HTTP embedding API.
|
||||
- **Configurable chunking strategies**: The current chunker is specialised for Markdown. Plain text, HTML, or code files would benefit from different splitting strategies selectable via a strategy interface.
|
||||
- **Metadata filtering**: The current filter mechanism supports exact-match on a single field (`category`). Range queries and multi-field filters would require extending the `VectorStore` interface or passing an opaque filter type.
|
||||
Loading…
Add table
Reference in a new issue