Compare commits
2
Commits
bec28f9014
...
bb8bc0478c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb8bc0478c | ||
|
|
a961a3c064 |
@@ -15,7 +15,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
INGESTION_IMAGE: git.d-ma.be/mathias/ingestion
|
INGESTION_IMAGE: git.d-ma.be/mathias/ingestion
|
||||||
ROUTING_IMAGE: git.d-ma.be/mathias/routing
|
ROUTING_IMAGE: git.d-ma.be/mathias/routing
|
||||||
INFRA_REPO: git@gitea.d-ma.be:mathias/infra.git
|
INFRA_REPO: git@git.d-ma.be:mathias/infra.git
|
||||||
BUILDKIT_HOST: unix:///run/buildkit/buildkitd.sock
|
BUILDKIT_HOST: unix:///run/buildkit/buildkitd.sock
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
|
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
|
||||||
chmod 600 ~/.ssh/infra_deploy_key
|
chmod 600 ~/.ssh/infra_deploy_key
|
||||||
printf 'Host gitea.d-ma.be\n HostName 127.0.0.1\n Port 30022\n StrictHostKeyChecking no\n' >> ~/.ssh/config
|
printf 'Host git.d-ma.be\n HostName 127.0.0.1\n Port 30022\n StrictHostKeyChecking no\n' >> ~/.ssh/config
|
||||||
|
|
||||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
||||||
git clone "${INFRA_REPO}" /tmp/infra-update
|
git clone "${INFRA_REPO}" /tmp/infra-update
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package vectorstore
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NumberedChunk pairs a chunk's body with the storage path it will use
|
// NumberedChunk pairs a chunk's body with the storage path it will use
|
||||||
@@ -66,6 +67,70 @@ func ChunkMarkdown(content string, maxBytes int) []string {
|
|||||||
}
|
}
|
||||||
out = append(out, splitAtParagraphs(s, maxBytes)...)
|
out = append(out, splitAtParagraphs(s, maxBytes)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final guarantee: no chunk exceeds maxBytes. A single heading-less,
|
||||||
|
// paragraph-less block (JSON-lines, minified content) survives the two
|
||||||
|
// passes above whole — splitAtParagraphs emits an over-budget paragraph
|
||||||
|
// rather than truncating prose. Hard-split any such chunk at line/rune
|
||||||
|
// boundaries so the embedder never rejects an over-context chunk.
|
||||||
|
final := make([]string, 0, len(out))
|
||||||
|
for _, c := range out {
|
||||||
|
if len(c) <= maxBytes {
|
||||||
|
final = append(final, c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
final = append(final, hardSplit(c, maxBytes)...)
|
||||||
|
}
|
||||||
|
return final
|
||||||
|
}
|
||||||
|
|
||||||
|
// hardSplit slices s into pieces no larger than maxBytes, breaking at line
|
||||||
|
// boundaries where possible and otherwise mid-line at a UTF-8 rune boundary.
|
||||||
|
// Last resort for content that has neither headings nor blank-line paragraphs.
|
||||||
|
func hardSplit(s string, maxBytes int) []string {
|
||||||
|
var out []string
|
||||||
|
var cur strings.Builder
|
||||||
|
flush := func() {
|
||||||
|
if cur.Len() > 0 {
|
||||||
|
out = append(out, cur.String())
|
||||||
|
cur.Reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, line := range strings.SplitAfter(s, "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(line) > maxBytes {
|
||||||
|
flush()
|
||||||
|
out = append(out, runeSplit(line, maxBytes)...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cur.Len() > 0 && cur.Len()+len(line) > maxBytes {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
cur.WriteString(line)
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// runeSplit slices s into <=maxBytes pieces without splitting a UTF-8 rune.
|
||||||
|
func runeSplit(s string, maxBytes int) []string {
|
||||||
|
var out []string
|
||||||
|
for len(s) > maxBytes {
|
||||||
|
cut := maxBytes
|
||||||
|
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||||
|
cut--
|
||||||
|
}
|
||||||
|
if cut == 0 { // single rune wider than the budget; emit it whole
|
||||||
|
cut = maxBytes
|
||||||
|
}
|
||||||
|
out = append(out, s[:cut])
|
||||||
|
s = s[cut:]
|
||||||
|
}
|
||||||
|
if len(s) > 0 {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,23 @@ func TestChunkMarkdown_SplitsAtHeadings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestChunkMarkdown_HardSplitsHeadinglessOversizedBlock(t *testing.T) {
|
||||||
|
// A document with no headings and no blank-line paragraph breaks (e.g.
|
||||||
|
// JSON-lines like wiki/telos/decisions/human-intent-column.md). The old
|
||||||
|
// chunker emitted it as one over-budget chunk → nomic-embed returned
|
||||||
|
// "input length exceeds the context length" (400). Every chunk must now
|
||||||
|
// fit the budget, with no content lost.
|
||||||
|
maxBytes := 200
|
||||||
|
src := strings.Repeat("x", 1000) // one 1000-byte blob, no headings, no \n\n
|
||||||
|
out := vectorstore.ChunkMarkdown(src, maxBytes)
|
||||||
|
|
||||||
|
require.Greater(t, len(out), 1, "oversized blob must be split")
|
||||||
|
for i, c := range out {
|
||||||
|
assert.LessOrEqual(t, len(c), maxBytes, "chunk %d over budget: %d bytes", i, len(c))
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1000, strings.Count(strings.Join(out, ""), "x"), "no content lost")
|
||||||
|
}
|
||||||
|
|
||||||
func TestChunkMarkdown_FurtherSplitsOversizedSection(t *testing.T) {
|
func TestChunkMarkdown_FurtherSplitsOversizedSection(t *testing.T) {
|
||||||
// One H2 section with 4 paragraphs of ~80 chars each, limit 100.
|
// One H2 section with 4 paragraphs of ~80 chars each, limit 100.
|
||||||
src := "## big\n\n" +
|
src := "## big\n\n" +
|
||||||
|
|||||||
Reference in New Issue
Block a user