Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
name: CI

on:
push:
branches: [dev, main]
pull_request:
branches: [dev, main]

permissions:
contents: read

env:
GOFLAGS: -buildvcs=false
GOWORK: "off"
GOPROXY: "direct"
GOSUMDB: "off"

jobs:
test:
name: Test + Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version: '1.26'
- name: Test with coverage
working-directory: go
run: go test -race -coverprofile=coverage.out -covermode=atomic -count=1 ./...
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: go/coverage.out
flags: unittests
fail_ci_if_error: false

lint:
name: golangci-lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version: '1.26'
- uses: golangci/golangci-lint-action@v9
with:
version: latest
working-directory: go
args: --timeout=5m --tests=false

sonarcloud:
name: SonarCloud
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version: '1.26'
- name: Test for coverage
working-directory: go
run: go test -coverprofile=coverage.out -covermode=atomic -count=1 ./...
- name: SonarCloud Scan
uses: SonarSource/sonarqube-scan-action@v6
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
args: >
-Dsonar.organization=dappcore
-Dsonar.projectKey=dappcore_go-rag
-Dsonar.sources=go
-Dsonar.exclusions=**/vendor/**,**/third_party/**,**/.tmp/**,**/*_test.go
-Dsonar.tests=go
-Dsonar.test.inclusions=**/*_test.go
-Dsonar.go.coverage.reportPaths=go/coverage.out
8 changes: 8 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[submodule "external/go"]
path = external/go
url = https://github.com/dappcore/go.git
branch = dev
[submodule "external/log"]
path = external/log
url = https://github.com/dappcore/go-log.git
branch = dev
4 changes: 2 additions & 2 deletions .woodpecker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ steps:
GOFLAGS: -buildvcs=false
GOWORK: "off"
commands:
- golangci-lint run --timeout=5m ./...
- cd go && golangci-lint run --timeout=5m ./...

- name: go-test
image: golang:1.26-alpine
Expand All @@ -25,7 +25,7 @@ steps:
CGO_ENABLED: "1"
commands:
- apk add --no-cache git build-base
- go test -race -coverprofile=coverage.out -covermode=atomic -count=1 ./...
- cd go && go test -race -coverprofile=coverage.out -covermode=atomic -count=1 ./...
- name: sonar
image: sonarsource/sonar-scanner-cli:latest
depends_on: [go-test]
Expand Down
69 changes: 50 additions & 19 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,49 +2,80 @@

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Module: `forge.lthn.ai/core/go-rag`
Module: `dappco.re/go/rag`

## Repo layout

All Go sources, module files, and Go-only infra are now under `go/`.

```text
/ # Repo root
├── go/
│ ├── go.mod
│ ├── go.sum
│ ├── go.work
│ ├── go.work.sum
│ ├── cmd/
│ ├── internal/
│ ├── tests/
│ ├── tools/
│ ├── *.go
│ ├── README.md -> symlink to root README.md
│ ├── CLAUDE.md -> symlink to root CLAUDE.md
│ ├── AGENTS.md -> symlink to root AGENTS.md
│ └── docs/ -> symlink to root docs/
├── docs/ (RAG specs and docs)
├── specs/ (RAG spec definitions)
├── README.md
├── CLAUDE.md
├── AGENTS.md
├── CONTRIBUTING.md
├── sonar-project.properties
├── LICENSE / LICENCE
└── .woodpecker.yml
```

Retrieval-Augmented Generation library for Go — document chunking, Ollama embeddings, Qdrant vector storage and cosine-similarity search.

## Commands

```bash
go build ./... # Build library + CLI
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 -race ./... # Race detector
go test -bench=. -benchmem ./... # Benchmarks (mock-only)
go test -tags rag -bench=. ./... # Benchmarks with live services
gofmt -w . # Format
go vet ./... # Vet
golangci-lint run ./... # Lint
cd go && go build ./... # Build library + CLI
cd go && go test ./... # Unit + mock tests (no services needed)
cd go && go test -tags rag ./... # Full suite including live Qdrant + Ollama
cd go && go test -v -run TestName ./... # Single test
cd go && go test -race ./... # Race detector
cd go && go test -bench=. -benchmem ./... # Benchmarks (mock-only)
cd go && go test -tags rag -bench=. ./... # Benchmarks with live services
cd go && gofmt -w . # Format
cd go && go vet ./... # Vet
cd go && golangci-lint run ./... # Lint
```

## Architecture

The library is built around two core interfaces (`Embedder` in `embedder.go`, `VectorStore` in `vectorstore.go`) that decouple business logic from service implementations. All pipeline code operates against these interfaces; concrete clients (`OllamaClient`, `QdrantClient`) and test mocks (`mockEmbedder`, `mockVectorStore` in `mock_test.go`) satisfy them.
The library is built around two core interfaces (`Embedder` in `go/embedder.go`, `VectorStore` in `go/vectorstore.go`) that decouple business logic from service implementations. All pipeline code operates against these interfaces; concrete clients (`OllamaClient`, `QdrantClient`) and test mocks (`mockEmbedder`, `mockVectorStore` in `go/mock_test.go`) satisfy them.

**Two pipeline flows** drive everything — see `docs/architecture.md` for the full data-flow diagrams:
**Two pipeline flows** drive everything:

1. **Ingestion** (`ingest.go`): directory walk -> `ChunkMarkdown` (three-level split in `chunk.go`) -> embed per chunk -> batch upsert to vector store
2. **Query** (`query.go`): embed question -> vector search -> threshold filter -> optional keyword boosting (`keyword.go`) -> format results (text/XML/JSON)
1. **Ingestion** (`go/ingest.go`): directory walk -> `ChunkMarkdown` (three-level split in `go/chunk.go`) -> embed per chunk -> batch upsert to vector store
2. **Query** (`go/query.go`): embed question -> vector search -> threshold filter -> optional keyword boosting (`go/keyword.go`) -> format results (text/XML/JSON)

**Two-tier helper pattern** in `helpers.go`:
**Two-tier helper pattern** in `go/helpers.go`:
- `*With` variants (e.g. `QueryWith`, `IngestDirWith`) accept pre-constructed `VectorStore`+`Embedder` — use for long-lived processes
- Default-client wrappers (e.g. `QueryDocs`, `IngestDirectory`) create fresh connections per call with health checks — use for CLI/one-shot operations

**CLI** (`cmd/rag/`): CLI subcommands registered via `AddRAGSubcommands()`, mounted under `core ai rag`. Uses `forge.lthn.ai/core/cli` and `forge.lthn.ai/core/go-i18n` for i18n'd flag descriptions.
**CLI** (`go/cmd/rag/`): CLI subcommands registered via `AddRAGSubcommands()`, mounted under `core ai rag`.

## Coding Standards

- 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: stdlib `testing` with the local helpers in `test_helpers_test.go`; do not add testify
- Tests: stdlib `testing` with the local helpers in `go/test_helpers_test.go`; do not add testify
- Integration tests: `//go:build rag` build tag — requires live Qdrant + Ollama
- Mocks: `mockEmbedder` and `mockVectorStore` in `mock_test.go` — error injection via fields, call tracking for verification
- Mocks: `mockEmbedder` and `mockVectorStore` in `go/mock_test.go` — error injection via fields, call tracking for verification

## Environment Variables

Expand Down
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
[![Go Reference](https://pkg.go.dev/badge/forge.lthn.ai/core/go-rag.svg)](https://pkg.go.dev/forge.lthn.ai/core/go-rag)
[![License: EUPL-1.2](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE.md)
[![Go Version](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat&logo=go)](go.mod)
<!-- SPDX-License-Identifier: EUPL-1.2 -->



> Retrieval-augmented generation — chunking, embeddings, qdrant + ollama

[![CI](https://github.com/dappcore/go-rag/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/dappcore/go-rag/actions/workflows/ci.yml)
[![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=dappcore_go-rag&metric=alert_status)](https://sonarcloud.io/dashboard?id=dappcore_go-rag)
[![Coverage](https://codecov.io/gh/dappcore/go-rag/branch/dev/graph/badge.svg)](https://codecov.io/gh/dappcore/go-rag)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=dappcore_go-rag&metric=security_rating)](https://sonarcloud.io/dashboard?id=dappcore_go-rag)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=dappcore_go-rag&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=dappcore_go-rag)
[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=dappcore_go-rag&metric=reliability_rating)](https://sonarcloud.io/dashboard?id=dappcore_go-rag)
[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=dappcore_go-rag&metric=code_smells)](https://sonarcloud.io/dashboard?id=dappcore_go-rag)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=dappcore_go-rag&metric=ncloc)](https://sonarcloud.io/dashboard?id=dappcore_go-rag)
[![Go Reference](https://pkg.go.dev/badge/dappco.re/go/go-rag.svg)](https://pkg.go.dev/dappco.re/go/go-rag)
[![License: EUPL-1.2](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](https://eupl.eu/1.2/en/)

# go-rag

Retrieval-Augmented Generation library for Go. Provides document chunking with three-level Markdown splitting plus sentence- and paragraph-based chunkers, configurable overlap, embedding generation via Ollama, vector storage and cosine-similarity search via Qdrant (gRPC), TF-IDF keyword fallback and keyword boosting post-filter, and result formatting in plain text, XML (for LLM prompt injection), or JSON. Ingestion accepts Markdown, text, PDF, and `.markdown` documents via `ShouldProcess()` / `FileExtensions()`. Designed around `Embedder` and `VectorStore` interfaces that decouple business logic from service implementations and enable mock-based testing.

Expand Down
1 change: 1 addition & 0 deletions external/go
Submodule go added at d661b7
1 change: 1 addition & 0 deletions external/log
Submodule log added at df0529
11 changes: 9 additions & 2 deletions go.work
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
go 1.26.0
go 1.26.2

use .
// Workspace mode for development: pulls fresh code from external/ submodules.
// CI uses GOWORK=off to fall back to go/go.mod tags (reproducible).

use (
./go
./external/go
./external/log
)
1 change: 1 addition & 0 deletions go/AGENTS.md
1 change: 1 addition & 0 deletions go/CLAUDE.md
1 change: 1 addition & 0 deletions go/README.md
File renamed without changes.
File renamed without changes.
22 changes: 8 additions & 14 deletions chunk.go → go/chunk.go
Original file line number Diff line number Diff line change
Expand Up @@ -556,14 +556,13 @@ func splitBySentencesSeq(text string) iter.Seq[string] {

for len(remaining) > 0 {
boundary := -1

BoundarySearch:
for i, r := range remaining {
switch r {
case '.', '!', '?', '\n':
boundary = i + len(string(r))
break
}
if boundary >= 0 {
break
break BoundarySearch
}
}

Expand All @@ -587,11 +586,6 @@ func splitBySentencesSeq(text string) iter.Seq[string] {
}
}

// splitBySections splits text by ## headers while preserving the header with its content.
func splitBySections(text string) []string {
return slices.Collect(splitBySectionsSeq(text))
}

// splitBySectionsSeq returns an iterator that yields text sections split by ## headers.
func splitBySectionsSeq(text string) iter.Seq[string] {
return func(yield func(string) bool) {
Expand All @@ -618,11 +612,6 @@ func splitBySectionsSeq(text string) iter.Seq[string] {
}
}

// splitByParagraphs splits text by double newlines.
func splitByParagraphs(text string) []string {
return slices.Collect(splitByParagraphsSeq(text))
}

// splitByParagraphsSeq returns an iterator that yields paragraphs split by double newlines.
func splitByParagraphsSeq(text string) iter.Seq[string] {
return func(yield func(string) bool) {
Expand Down Expand Up @@ -746,6 +735,11 @@ func markdownSectionTitle(section string) (string, string) {
return parentTitle, title
}

var (
_ = splitBySentences
_ = indexOf
)

func indexOf(s, substr string) int {
if substr == "" || len(substr) > len(s) {
return -1
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
1 change: 1 addition & 0 deletions go/docs
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
3 changes: 3 additions & 0 deletions go/go.work
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
go 1.26.0

use .
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
8 changes: 5 additions & 3 deletions keyword.go → go/keyword.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,10 +221,10 @@ func tokenise(text string) []string {
}

for _, r := range text {
switch {
case r == ' ' || r == '\t' || r == '\n' || r == '\r':
switch r {
case ' ', '\t', '\n', '\r':
flush()
case r == '.' || r == ',' || r == ';' || r == ':' || r == '!' || r == '?' || r == '"' || r == '\'' || r == '(' || r == ')' || r == '[' || r == ']' || r == '{' || r == '}' || r == '<' || r == '>':
case '.', ',', ';', ':', '!', '?', '"', '\'', '(', ')', '[', ']', '{', '}', '<', '>':
flush()
default:
current.WriteRune(r)
Expand Down Expand Up @@ -340,6 +340,8 @@ func extractKeywordsSeq(query string) iter.Seq[string] {
}
}

var _ = fields

func fields(text string) []string {
var words []string
current := core.NewBuilder()
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
2 changes: 1 addition & 1 deletion sonar-project.properties
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ sonar.exclusions=**/vendor/**,**/third_party/**,**/.tmp/**,**/gomodcache/**,**/n
sonar.tests=.
sonar.test.inclusions=**/*_test.go,**/*.test.ts,**/*.test.js,**/*.spec.ts,**/*.spec.js
sonar.test.exclusions=**/vendor/**,**/third_party/**,**/.tmp/**,**/gomodcache/**,**/node_modules/**,**/dist/**,**/build/**
sonar.go.coverage.reportPaths=coverage.out
sonar.go.coverage.reportPaths=go/coverage.out
Loading