Files
xinmi/internal/searchutil/chunkmerge.go
T
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

267 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package searchutil
import (
"sort"
"strings"
"github.com/Tencent/WeKnora/internal/types"
)
// JoinChunkContent joins two current chunk bodies without relying on parser
// offsets. Exact containment is collapsed, a real suffix/prefix overlap is
// removed, and otherwise both bodies are retained with separator between
// them. The conservative fallback intentionally prefers small duplication
// over silently dropping edited content.
func JoinChunkContent(acc, next, separator string) string {
if acc == "" {
return next
}
if next == "" {
return acc
}
if ContainsChunkContent(acc, next) {
return acc
}
if ContainsChunkContent(next, acc) {
return next
}
accRunes := []rune(acc)
nextRunes := []rune(next)
maxOverlap := minInt(len(accRunes), len(nextRunes))
// Editable chunks may be much larger than parser-produced chunks. Bound
// suffix matching so an adversarial 200 KB edit cannot turn retrieval into
// quadratic work. Parser overlap windows are normally far below this cap;
// larger unmatched overlap is safely retained as duplication.
if maxOverlap > defaultSearchSpan {
maxOverlap = defaultSearchSpan
}
for overlap := maxOverlap; overlap >= minOverlapRunes; overlap-- {
if runeSlicesEqual(accRunes[len(accRunes)-overlap:], nextRunes[:overlap]) {
return acc + string(nextRunes[overlap:])
}
}
return acc + separator + next
}
// ContainsChunkContent reports whether the complete current body is safely
// represented by another body. Very short substrings are not treated as
// containment because common words and punctuation would create false drops.
func ContainsChunkContent(container, contained string) bool {
if container == "" || contained == "" {
return false
}
if container == contained {
return true
}
return len([]rune(contained)) >= minOverlapRunes && strings.Contains(container, contained)
}
func runeSlicesEqual(left, right []rune) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if left[i] != right[i] {
return false
}
}
return true
}
// 这里实现 chunk 内容的「重叠拼接」公共逻辑,供文档重建(reconstructContent)、
// 知识图谱内容合并(graph mergeChunkContents)等路径复用。聊天检索链路允许
// 用户编辑 Chunk,使用上面的 JoinChunkContent,避免依赖原文位置坐标。
//
// 历史上各处都用「按位置」的公式裁剪重叠(offset = len(content) - (EndAt -
// lastEndAt) 之类),它默认 len([]rune(Content)) == EndAt-StartAt。但有两类
// 数据会破坏这个不变式,导致拼接错位、丢字或重复:
// 1. 父子分块器会给被拆开的表格「补写表头」,补进去的表头是零宽度的
// (start == end),位置坐标无法表达它,content 比 EndAt-StartAt 更长;
// 2. content 里可能保留 HTML 实体(如 " / >),其字符数比原文区间长。
//
// 因此这里改为「按文本」匹配重叠:在下一段开头的窗口里查找已合并文本的后缀首次
// 出现的位置,从该位置之后接上。位置信息(StartAt/EndAt)仅用于估算搜索窗口
// 大小,不再用于裁剪。
const (
// minOverlapRunes 是参与匹配的最短后缀长度。太短(如表格分隔行 |---|)
// 容易误匹配,因此忽略。
minOverlapRunes = 12
// defaultSearchSpan 是搜索窗口的下限,保证即使位置信息缺失/为 0 也能
// 检测到一定范围内的真实重叠。
defaultSearchSpan = 400
)
// AppendWithOverlap 把 next 追加到 acc 之后,并去除二者之间的重叠部分。
//
// positionOverlap 是由 StartAt/EndAt 估算的重叠量(lastEnd - curStart),仅用于
// 界定搜索窗口大小;真正的重叠按文本匹配,能兼容补写表头与 HTML 实体长度偏差。
// 若找不到文本重叠,则原样拼接(不裁剪),宁可保留也不破坏内容。
//
// positionOverlap <= 0 时两段位置上严格相邻或不相交,没有可去重的重叠;
// 此时进入文本匹配会因 headSlack 下限 320 在 next 开头窗口内误命中
// acc 后缀的真实内容重复(如同一句话在文档多次出现),把 next 开头
// 整段误判为补写表头删掉,造成不可逆的内容丢失。直接拼接,补写表头
// 重复交给调用方后处理。
func AppendWithOverlap(acc, next string, positionOverlap int) string {
if acc == "" {
return next
}
if next == "" {
return acc
}
if positionOverlap <= 0 {
return acc + next
}
accRunes := []rune(acc)
nextRunes := []rune(next)
span := positionOverlap
maxK := minInt(len(accRunes), len(nextRunes))
if cap := maxInt(span*3, defaultSearchSpan); maxK > cap {
maxK = cap
}
// 重叠内容之前最多允许跳过多少前缀(即补写的表头等合成文本)。
headSlack := maxInt(span*2, 320)
for k := maxK; k >= minOverlapRunes; k-- {
needle := accRunes[len(accRunes)-k:]
if pos := indexRunes(nextRunes, needle, headSlack); pos >= 0 {
return acc + string(nextRunes[pos+k:])
}
}
return acc + next
}
// AppendWithExactOverlap 在调用方已确认位置坐标可信时,按坐标给出的精确重叠量
// 拼接 acc 与 next:校验 acc 的末 overlap 个字符与 next 的前 overlap 个字符逐字
// 符相等,相等则精确裁剪,overlap 为 0 时直接拼接。
//
// 与 AppendWithOverlap 的区别在于「不猜」:后者为兼容补写表头、HTML 实体等长度
// 偏差,会在窗口内搜索最长后缀匹配,重复周期性文本(表格、日志)可能被误判成重
// 叠而裁掉真实内容。坐标可信时重叠量是已知的,不需要搜索。
//
// 校验不通过返回 ok=false,由调用方决定是否回退到 AppendWithOverlap。
func AppendWithExactOverlap(acc, next string, overlap int) (string, bool) {
if acc == "" {
return next, true
}
if next == "" {
return acc, true
}
if overlap < 0 {
return "", false
}
if overlap == 0 {
return acc + next, true
}
accRunes := []rune(acc)
nextRunes := []rune(next)
if overlap > len(accRunes) || overlap > len(nextRunes) {
return "", false
}
if !runeSlicesEqual(accRunes[len(accRunes)-overlap:], nextRunes[:overlap]) {
return "", false
}
return acc + string(nextRunes[overlap:]), true
}
// MergeTextChunks 按 StartAt(并列时按 ChunkIndex)排序后,用 AppendWithOverlap
// 把多个 chunk 的内容重建为完整文本。gapSep 用于位置不相邻(有间隙)的两段之间
// 的分隔符(如 "\n"),传空串则直接拼接。
//
// 调用方负责先做类型过滤(例如只保留文本 chunk);本函数不感知 ChunkType。
func MergeTextChunks(chunks []*types.Chunk, gapSep string) string {
if len(chunks) == 0 {
return ""
}
sorted := make([]*types.Chunk, len(chunks))
copy(sorted, chunks)
sort.SliceStable(sorted, func(i, j int) bool {
if sorted[i].StartAt == sorted[j].StartAt {
return sorted[i].ChunkIndex < sorted[j].ChunkIndex
}
return sorted[i].StartAt < sorted[j].StartAt
})
merged := ""
mergedEnd := -1
for _, c := range sorted {
if c == nil || c.Content == "" {
continue
}
if merged == "" {
merged = c.Content
if c.EndAt > 0 {
mergedEnd = c.EndAt
}
continue
}
// 间隙 / 位置信息缺失(EndAt==0):作为独立段落拼接,不做重叠裁剪。
if c.StartAt > mergedEnd || c.EndAt == 0 {
if gapSep != "" {
merged += gapSep
}
merged += c.Content
if c.EndAt > 0 {
mergedEnd = c.EndAt
}
continue
}
// 部分重叠或首尾相接:按文本匹配去重叠后拼接。
if c.EndAt > mergedEnd {
merged = AppendWithOverlap(merged, c.Content, mergedEnd-c.StartAt)
mergedEnd = c.EndAt
}
// 否则被上一段完全覆盖,跳过。
}
return merged
}
// indexRunes 在 haystack 中查找 needle 首次出现的 rune 下标,且起始位置不超过
// maxStart。找不到返回 -1。
func indexRunes(haystack, needle []rune, maxStart int) int {
if len(needle) == 0 || len(needle) > len(haystack) {
return -1
}
limit := len(haystack) - len(needle)
if maxStart < limit {
limit = maxStart
}
for i := 0; i <= limit; i++ {
match := true
for j := 0; j < len(needle); j++ {
if haystack[i+j] != needle[j] {
match = false
break
}
}
if match {
return i
}
}
return -1
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}