kohagi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4beb0ae3b0422e028e52f1e04c90e414f740bf412ef4a871d928f7e760730861
4
+ data.tar.gz: 7ac174c73b676551ba7ce105e396d1b133e298663d00193467520bb0a162d82e
5
+ SHA512:
6
+ metadata.gz: 2262d1a3cbb528dbe35615e1e548a7d4604c08635d3ea3a0211187c8d1fc12a1e940d5a80d2535a403d8e3b5fdfa071382ab2757efe2ce0c6a1921330d3adb8d
7
+ data.tar.gz: 312e26729165f64e008efaa94b83aff99430206ead9219a8a8d2e0fae67505ae385a93fe084cb42ac39107680b71d59cbcdb4a5d9358cba634a535073cc758bf
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-07-24
4
+
5
+ Initial release.
6
+
7
+ - `Kohagi::Client` — builds the kohagi command from 1:1 CLI-flag options,
8
+ spawns it deadlock-safely (writer thread + concurrent stderr drain), and
9
+ streams `Kohagi::Result` records back.
10
+ - Exit codes as outcomes: 0/2 return a `Kohagi::Summary` (`ok?` / `partial?`),
11
+ 1 raises `Kohagi::FatalError`, 3 raises `Kohagi::UnsupportedDeviceError` for
12
+ the CoreML → CPU fallback.
13
+ - `--report-tokens` surfaced as `Result#truncated?` / `#n_tokens` and the
14
+ summary's `truncated` count.
15
+ - `Client#with` for option overrides (the fallback pattern).
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 takahashim
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # Kohagi (Ruby)
2
+
3
+ A Ruby client for [Kohagi](https://github.com/takahashim/kohagi), a local CLI for generating sentence embeddings.
4
+
5
+ It runs the `kohagi` binary and safely handles its input and output streams.
6
+ It also reports execution errors and returns embeddings associated with their input IDs.
7
+
8
+ This gem handles only communication with the CLI, converting `{id, text}` records into `{id, embedding}` results.
9
+ How the embeddings are stored, validated, or used is left to your application.
10
+
11
+ ## Requirements
12
+
13
+ The `kohagi` binary must be available on `PATH`, or specified with the `bin:` option.
14
+
15
+ See the [Kohagi installation instructions](https://github.com/takahashim/kohagi#install).
16
+
17
+ This gem ships no native code and does not bundle the `kohagi` binary.
18
+
19
+ ## Install
20
+
21
+ ```ruby
22
+ gem "kohagi"
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ```ruby
28
+ require "kohagi"
29
+
30
+ client = Kohagi::Client.new(prefix: "検索文書: ")
31
+
32
+ records = [
33
+ { id: 1, text: "夏目漱石『吾輩は猫である』…" },
34
+ { id: 2, text: "太宰治『人間失格』…" },
35
+ ]
36
+
37
+ # Streaming: each result is yielded as it arrives, so memory stays flat on any corpus. Returns a Summary.
38
+ summary = client.embed(records) do |r|
39
+ store(r.id, r.embedding) # e.g. write to a pgvector column
40
+ end
41
+
42
+ summary.dim # => 512
43
+ summary.out # => 2 (records embedded)
44
+ summary.skipped # => 0
45
+ summary.truncated # => 0 (records that ran past --max-seq-length)
46
+ ```
47
+
48
+ Without a block the results are collected onto the summary:
49
+
50
+ ```ruby
51
+ summary = client.embed(records)
52
+ summary.results.each { |r| store(r.id, r.embedding) }
53
+ ```
54
+
55
+ ### Configuration
56
+
57
+ Each option maps directly to a `kohagi` CLI flag. Kohagi does not use a configuration file.
58
+
59
+ | Option | CLI flag or behavior |
60
+ | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
61
+ | `bin:` | Executable path. Defaults to `"kohagi"`. |
62
+ | `model_id:` | `--model-id` |
63
+ | `model_path:` and `tokenizer_path:` | `--model-path` and `--tokenizer-path` for offline use. These take precedence over `model_id:`. |
64
+ | `prefix:` | `--prefix` |
65
+ | `pooling:` | `--pooling` |
66
+ | `device:` | `--device` (`"cpu"`, `"metal"`, or `"coreml"`) |
67
+ | `coreml_dir:`, `coreml_model_id:`, and `coreml_prefer:` | Corresponding `--coreml-*` flags |
68
+ | `max_seq_length:`, `batch_size:`, and `precision:` | Corresponding CLI flags |
69
+ | `normalize:` | Passes `--no-normalize` when `false`. Normalization is enabled by default. |
70
+ | `report_tokens:` | Passes `--report-tokens`, adding `n_tokens` and `truncated?` to each result. |
71
+ | `logger:` | A callable that receives `kohagi`'s standard error output after each run. |
72
+
73
+ ### Truncation
74
+
75
+ Text longer than `max_seq_length` is truncated before embedding.
76
+ The default limit is 512 tokens.
77
+
78
+ The summary always includes the number of truncated inputs.
79
+ With `report_tokens: true`, each result also includes `n_tokens` and `truncated?`.
80
+ You can use this information to process truncated inputs separately:
81
+
82
+ ```ruby
83
+ client = Kohagi::Client.new(report_tokens: true)
84
+
85
+ client.embed(records) do |result|
86
+ chunk_and_reembed(result.id) if result.truncated?
87
+ end
88
+ ```
89
+
90
+ ### Exit codes
91
+
92
+ | `kohagi` exit code | Result |
93
+ | ------------------ | -------------------------------------------------------------------------------------------------------------------------- |
94
+ | 0 | Returns a `Summary` for which `summary.ok?` is true. |
95
+ | 2 | Returns a `Summary` for which `summary.partial?` is true. Some input lines were skipped, but the returned output is valid. |
96
+ | 1 | Raises `Kohagi::FatalError`. |
97
+ | 3 | Raises `Kohagi::UnsupportedDeviceError`. |
98
+
99
+ Exit code 3 indicates that the Core ML backend cannot handle the request.
100
+ You can retry using the CPU:
101
+
102
+ ```ruby
103
+ begin
104
+ client.embed(records, &block)
105
+ rescue Kohagi::UnsupportedDeviceError
106
+ client.with(device: "cpu").embed(records, &block)
107
+ end
108
+ ```
109
+
110
+ ## License
111
+
112
+ MIT
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "json"
5
+
6
+ module Kohagi
7
+ # Drives the kohagi binary's stdin/stdout JSONL protocol.
8
+ #
9
+ # kohagi is config-file-less, so every option here maps 1:1 to a CLI flag.
10
+ # The client owns only the transport: building the command, spawning the
11
+ # process without deadlocking on the pipe buffer, mapping exit codes to
12
+ # outcomes, and parsing the records back. The domain (which texts, what to do
13
+ # with the vectors) stays with the caller.
14
+ #
15
+ # client = Kohagi::Client.new(prefix: "検索文書: ", report_tokens: true)
16
+ # summary = client.embed(records) { |r| store(r.id, r.embedding) }
17
+ #
18
+ class Client
19
+ DEFAULTS = { bin: "kohagi", normalize: true, report_tokens: false }.freeze
20
+
21
+ # See the README for the full option list. Unknown keys are ignored, so a
22
+ # newer kohagi flag can be threaded through as `extra_flags`.
23
+ def initialize(**options)
24
+ @options = DEFAULTS.merge(options)
25
+ end
26
+
27
+ # A copy with some options overridden — for the exit-3 → CPU fallback:
28
+ #
29
+ # rescue Kohagi::UnsupportedDeviceError
30
+ # client.with(device: "cpu").embed(records, &block)
31
+ def with(**overrides)
32
+ self.class.new(**@options.merge(overrides))
33
+ end
34
+
35
+ # The argv kohagi is launched with. Pure and public, so a caller can assert
36
+ # on it without spawning a process.
37
+ def command
38
+ o = @options
39
+ cmd = [o.fetch(:bin)]
40
+ cmd.concat(model_flags(o))
41
+ cmd.concat(["--prefix", o[:prefix]]) if o[:prefix]
42
+ cmd.concat(device_flags(o))
43
+ {
44
+ "--pooling" => o[:pooling], "--max-seq-length" => o[:max_seq_length],
45
+ "--batch-size" => o[:batch_size], "--precision" => o[:precision]
46
+ }.each { |flag, val| cmd.concat([flag, val.to_s]) unless val.nil? }
47
+ cmd << "--no-normalize" unless o.fetch(:normalize)
48
+ cmd << "--report-tokens" if o.fetch(:report_tokens)
49
+ cmd.concat(Array(o[:extra_flags]))
50
+ cmd
51
+ end
52
+
53
+ # Embed `records` (each `{id:, text:}`). With a block, yields a Result per
54
+ # record as it arrives, so memory stays flat on any corpus; without one, the
55
+ # results are collected into the returned Summary. Returns a Summary either
56
+ # way.
57
+ #
58
+ # Exit codes become outcomes: 0 and 2 return normally (2 = some lines were
59
+ # skipped, but the output you received is valid); 1 raises FatalError; 3
60
+ # raises UnsupportedDeviceError so the caller can retry on
61
+ # `with(device: "cpu")`.
62
+ def embed(records, &block)
63
+ collected = block ? nil : []
64
+ emit = block || ->(r) { collected << r }
65
+ dim = nil
66
+ out = 0
67
+
68
+ Open3.popen3(*command) do |stdin, stdout, stderr, wait|
69
+ # Write from one thread and read from this one — writing the whole
70
+ # corpus before reading anything can deadlock both processes once the
71
+ # pipe buffer fills. Drain stderr concurrently for the same reason.
72
+ writer = Thread.new do
73
+ records.each { |rec| stdin.puts(JSON.generate(id: rec.fetch(:id), text: rec.fetch(:text))) }
74
+ rescue Errno::EPIPE
75
+ # kohagi exited before reading all input (e.g. exit 3, detected up
76
+ # front). The process exit code carries the real reason; the broken
77
+ # pipe here is a symptom, not the error to surface.
78
+ ensure
79
+ begin
80
+ stdin.close
81
+ rescue Errno::EPIPE
82
+ nil
83
+ end
84
+ end
85
+ errors = Thread.new { stderr.read }
86
+
87
+ stdout.each_line do |line|
88
+ result = Result.from_json(line)
89
+ dim ||= result.embedding&.size
90
+ out += 1
91
+ emit.call(result)
92
+ end
93
+
94
+ writer.join
95
+ stderr_text = errors.value
96
+ @options[:logger]&.call(stderr_text) unless stderr_text.to_s.empty?
97
+ code = wait.value.exitstatus
98
+ raise_for(code, stderr_text)
99
+
100
+ return build_summary(stderr_text, sent: records.size, out: out, dim: dim, code: code, results: collected)
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def raise_for(code, stderr)
107
+ case code
108
+ when ExitCode::OK, ExitCode::PARTIAL then nil
109
+ when ExitCode::UNSUPPORTED then raise UnsupportedDeviceError.new(stderr, exit_code: code)
110
+ else raise FatalError.new(stderr, exit_code: code)
111
+ end
112
+ end
113
+
114
+ # Combine kohagi's reported counts with what we counted from the stream,
115
+ # preferring kohagi's numbers and falling back to the stream when a field
116
+ # (or the whole summary line) is absent.
117
+ def build_summary(stderr, sent:, out:, dim:, code:, results:)
118
+ reported = SummaryLine.parse(stderr)
119
+ Summary.new(
120
+ model: reported&.model,
121
+ dim: reported&.dim || dim,
122
+ sent: sent,
123
+ out: reported&.out || out,
124
+ skipped: reported&.skipped || (sent - out),
125
+ truncated: reported&.truncated,
126
+ exit_code: code,
127
+ results: results
128
+ )
129
+ end
130
+
131
+ # Local weights win over a Hub id; both absent lets kohagi use its default.
132
+ def model_flags(o)
133
+ if o[:model_path] && o[:tokenizer_path]
134
+ ["--model-path", o[:model_path].to_s, "--tokenizer-path", o[:tokenizer_path].to_s]
135
+ elsif o[:model_id]
136
+ ["--model-id", o[:model_id].to_s]
137
+ else
138
+ []
139
+ end
140
+ end
141
+
142
+ # --device, plus the converted-model pointer CoreML needs (--coreml-dir
143
+ # wins over --coreml-model-id, matching kohagi).
144
+ def device_flags(o)
145
+ return [] unless o[:device]
146
+
147
+ flags = ["--device", o[:device].to_s]
148
+ return flags unless o[:device].to_s == "coreml"
149
+
150
+ flags.concat(["--coreml-dir", o[:coreml_dir].to_s]) if o[:coreml_dir]
151
+ flags.concat(["--coreml-model-id", o[:coreml_model_id].to_s]) if o[:coreml_model_id] && !o[:coreml_dir]
152
+ flags.concat(["--coreml-prefer", o[:coreml_prefer].to_s]) if o[:coreml_prefer]
153
+ flags
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kohagi
4
+ # Base for every error the client raises. Carries the process exit code and
5
+ # the captured stderr so the caller can log or branch on them.
6
+ class Error < StandardError
7
+ attr_reader :exit_code, :stderr
8
+
9
+ def initialize(stderr = nil, exit_code: nil)
10
+ @stderr = stderr
11
+ @exit_code = exit_code
12
+ super(build_message(stderr, exit_code))
13
+ end
14
+
15
+ private
16
+
17
+ def build_message(stderr, code)
18
+ base = "kohagi exited #{code}"
19
+ tail = stderr.to_s.lines.last(3).map(&:chomp).reject(&:empty?).join(" / ")
20
+ tail.empty? ? base : "#{base}: #{tail}"
21
+ end
22
+ end
23
+
24
+ # Exit 1: model load failure, bad flags, I/O error. Nothing to retry.
25
+ class FatalError < Error; end
26
+
27
+ # Exit 3: the requested CoreML backend cannot serve the request (built without
28
+ # the feature, no converted model given, or a sequence past the largest
29
+ # bucket). Detected before any input is read, so no output was produced — the
30
+ # caller can retry on `client.with(device: "cpu")`.
31
+ class UnsupportedDeviceError < Error; end
32
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kohagi
4
+ # The exit codes kohagi uses (see PROTOCOL.md), named in one place so the
5
+ # raise-vs-return decision (Client#raise_for) and the outcome classification
6
+ # (Summary#ok? / #partial?) can't drift apart.
7
+ module ExitCode
8
+ OK = 0 # every record embedded
9
+ PARTIAL = 2 # finished, but some input lines were skipped; output valid
10
+ FATAL = 1 # model load failure, bad flags, I/O error
11
+ UNSUPPORTED = 3 # the requested CoreML backend cannot serve the request
12
+ end
13
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Kohagi
6
+ # One output record: the echoed id and its vector, plus the optional
7
+ # `--report-tokens` fields. `id` is whatever JSON value was sent, unchanged.
8
+ Result = Struct.new(:id, :embedding, :n_tokens, :truncated, keyword_init: true) do
9
+ # True only when kohagi reported this text as truncated (needs
10
+ # `report_tokens: true`); nil/false otherwise.
11
+ def truncated?
12
+ truncated == true
13
+ end
14
+
15
+ # Parse one JSONL line from kohagi's stdout.
16
+ def self.from_json(line)
17
+ row = JSON.parse(line)
18
+ new(
19
+ id: row["id"],
20
+ embedding: row["embedding"],
21
+ n_tokens: row["n_tokens"],
22
+ truncated: row["truncated"]
23
+ )
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kohagi
4
+ # The run's outcome: kohagi's reported counts (see SummaryLine) combined by the
5
+ # client with what actually streamed back. A plain data holder — parsing and
6
+ # composition live in SummaryLine and Client, not here.
7
+ #
8
+ # `truncated` is nil against a kohagi old enough not to report it.
9
+ Summary = Struct.new(
10
+ :model, :dim, :sent, :out, :skipped, :truncated, :exit_code, :results,
11
+ keyword_init: true
12
+ ) do
13
+ # Every record embedded.
14
+ def ok?
15
+ exit_code == ExitCode::OK
16
+ end
17
+
18
+ # Finished, but some input lines were skipped. The received output is still
19
+ # valid; investigate stderr and resend the skipped records.
20
+ def partial?
21
+ exit_code == ExitCode::PARTIAL
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kohagi
4
+ # kohagi's one-line run summary from stderr, parsed into a value object:
5
+ #
6
+ # kohagi: model=cl-nagoya/ruri-v3-130m dim=512 in=2141 out=2141 skipped=0 truncated=3
7
+ #
8
+ # `SummaryLine.parse` returns an instance, or nil when stderr carries no such
9
+ # line, so the caller can fall back to values it computed from the stream. A
10
+ # field the line omits (an older kohagi has no `truncated`) reads as nil.
11
+ # Knowing kohagi's stderr text format lives here, not in the Summary data.
12
+ class SummaryLine
13
+ MARKER = "kohagi: model="
14
+ MODEL = /\bmodel=(?<value>\S+)/
15
+ NUMERIC = %i[dim out skipped truncated].freeze
16
+
17
+ attr_reader :model, :dim, :out, :skipped, :truncated
18
+
19
+ # The parsed summary line in `stderr`, or nil if there isn't one.
20
+ def self.parse(stderr)
21
+ line = stderr.to_s.lines.reverse.find { |l| l.include?(MARKER) }
22
+ line && new(line)
23
+ end
24
+
25
+ def initialize(line)
26
+ @model = capture(line, MODEL)
27
+ NUMERIC.each do |name|
28
+ value = capture(line, /\b#{name}=(?<value>\d+)\b/)
29
+ instance_variable_set("@#{name}", value&.to_i)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def capture(line, regexp)
36
+ line.match(regexp)&.[](:value)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kohagi
4
+ VERSION = "0.1.0"
5
+ end
data/lib/kohagi.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "kohagi/version"
4
+ require_relative "kohagi/exit_code"
5
+ require_relative "kohagi/errors"
6
+ require_relative "kohagi/result"
7
+ require_relative "kohagi/summary_line"
8
+ require_relative "kohagi/summary"
9
+ require_relative "kohagi/client"
10
+
11
+ # Ruby client for the kohagi local sentence-embedding CLI.
12
+ # See https://github.com/takahashim/kohagi/blob/main/PROTOCOL.md for the
13
+ # stdin/stdout protocol this wraps.
14
+ module Kohagi
15
+ end
metadata ADDED
@@ -0,0 +1,58 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kohagi
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - takahashim
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: |
13
+ Drives the kohagi binary's stdin/stdout JSONL protocol from Ruby: builds the
14
+ command, spawns it without deadlocking on the pipe buffer, maps exit codes to
15
+ outcomes, and returns id-tagged embeddings. Shells out to an installed kohagi
16
+ binary and ships no native code of its own.
17
+ email:
18
+ - takahashimm@gmail.com
19
+ executables: []
20
+ extensions: []
21
+ extra_rdoc_files: []
22
+ files:
23
+ - CHANGELOG.md
24
+ - LICENSE
25
+ - README.md
26
+ - lib/kohagi.rb
27
+ - lib/kohagi/client.rb
28
+ - lib/kohagi/errors.rb
29
+ - lib/kohagi/exit_code.rb
30
+ - lib/kohagi/result.rb
31
+ - lib/kohagi/summary.rb
32
+ - lib/kohagi/summary_line.rb
33
+ - lib/kohagi/version.rb
34
+ homepage: https://github.com/takahashim/kohagi-ruby
35
+ licenses:
36
+ - MIT
37
+ metadata:
38
+ source_code_uri: https://github.com/takahashim/kohagi-ruby
39
+ changelog_uri: https://github.com/takahashim/kohagi-ruby/blob/main/CHANGELOG.md
40
+ rubygems_mfa_required: 'true'
41
+ rdoc_options: []
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '3.0'
49
+ required_rubygems_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ requirements: []
55
+ rubygems_version: 4.0.16
56
+ specification_version: 4
57
+ summary: Ruby client for the kohagi local sentence-embedding CLI
58
+ test_files: []