Files
rag-local/kb/dao/kg_entity_dao.go
T
2026-08-11 11:19:04 +08:00

250 lines
8.3 KiB
Go

package dao
import (
"context"
"database/sql"
"errors"
"strings"
"rag-local/kb/consts"
"rag-local/kb/model/entity"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
var KgEntity = &kgEntityDao{}
type kgEntityDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB(consts.DbGroupDefault).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameKgEntity+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dataset_id INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL DEFAULT '',
entity_type TEXT NOT NULL DEFAULT '',
chunk_id INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT (datetime('now','localtime')),
updated_at DATETIME DEFAULT (datetime('now','localtime')),
UNIQUE(dataset_id, name)
)`)
if err != nil {
g.Log().Warningf(ctx, "create kg_entity table failed: %v", err)
}
if _, err := g.DB(consts.DbGroupDefault).Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_kg_entity_dataset ON "+consts.TableNameKgEntity+"(dataset_id)"); err != nil {
g.Log().Warningf(ctx, "create index idx_kg_entity_dataset failed: %v", err)
}
}
func (d *kgEntityDao) GetOne(ctx context.Context, id int64) (*entity.KgEntity, error) {
var m entity.KgEntity
err := g.DB(consts.DbGroupDefault).Model(consts.TableNameKgEntity).Ctx(ctx).Where("id", id).Scan(&m)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &m, nil
}
func (d *kgEntityDao) List(ctx context.Context, datasetId int64, page, pageSize int) ([]*entity.KgEntity, int, error) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
m := g.DB(consts.DbGroupDefault).Model(consts.TableNameKgEntity).Ctx(ctx)
if datasetId > 0 {
m = m.Where("dataset_id", datasetId)
}
total, err := m.Count()
if err != nil {
return nil, 0, err
}
var list []*entity.KgEntity
err = m.Page(page, pageSize).OrderDesc("id").Scan(&list)
if list == nil {
list = make([]*entity.KgEntity, 0)
}
return list, total, err
}
// ListNames 数据集全部实体名(实体链接在内存中打分,本地库规模可控)
func (d *kgEntityDao) ListNames(ctx context.Context, datasetId int64) ([]string, error) {
var list []*entity.KgEntity
err := g.DB(consts.DbGroupDefault).Model(consts.TableNameKgEntity).Ctx(ctx).
Fields("name").Where("dataset_id", datasetId).Scan(&list)
if err != nil {
return nil, err
}
names := make([]string, 0, len(list))
for _, e := range list {
names = append(names, e.Name)
}
return names, nil
}
// KgEntityItem 批量写入条目
type KgEntityItem struct {
Name string
EntityType string
}
// UpsertBatch 按 (dataset_id, name) 去重批量写入,已存在则更新类型与来源(单条 SQL 多行 VALUES)
func (d *kgEntityDao) UpsertBatch(ctx context.Context, datasetId, chunkId int64, items []KgEntityItem) error {
if len(items) == 0 {
return nil
}
now := gtime.Now().Format("Y-m-d H:i:s")
// SQLite 变量数上限 999,按 100 行/条分片
for start := 0; start < len(items); start += 100 {
end := min(start+100, len(items))
var sb strings.Builder
sb.WriteString("INSERT INTO " + consts.TableNameKgEntity + `
(dataset_id, name, entity_type, chunk_id, created_at, updated_at)
VALUES `)
args := make([]any, 0, (end-start)*6)
for i := start; i < end; i++ {
if i > start {
sb.WriteString(",")
}
sb.WriteString("(?,?,?,?,?,?)")
args = append(args, datasetId, items[i].Name, items[i].EntityType, chunkId, now, now)
}
sb.WriteString(`
ON CONFLICT(dataset_id, name) DO UPDATE SET
entity_type=excluded.entity_type, chunk_id=excluded.chunk_id, updated_at=excluded.updated_at`)
if _, err := g.DB(consts.DbGroupDefault).Exec(ctx, sb.String(), args...); err != nil {
return err
}
}
return nil
}
// HasGraphByDocument 判定某文档是否产出过图谱(单表约束:先取分块 id,再数关系)。
// 不用实体行计数:实体按 (dataset_id,name) 去重、chunk_id 会被跨文件 upsert 覆盖,按实体计数会漏计。
// 关系每 chunk 至少一条、不受覆盖影响。
func (d *kgEntityDao) HasGraphByDocument(ctx context.Context, documentId int64) (bool, error) {
chunkIds, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameChunk).Ctx(ctx).
Fields("id").Where("document_id", documentId).Array()
if err != nil {
return false, err
}
// 分块数可能超 SQLite 变量上限,按 100 分批
for start := 0; start < len(chunkIds); start += 100 {
end := min(start+100, len(chunkIds))
ids := make([]int64, 0, end-start)
for _, v := range chunkIds[start:end] {
ids = append(ids, v.Int64())
}
n, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameKgRelation).Ctx(ctx).
WhereIn("chunk_id", ids).Count()
if err != nil {
return false, err
}
if n > 0 {
return true, nil
}
}
return false, nil
}
func (d *kgEntityDao) DeleteByChunkIds(ctx context.Context, chunkIds []int64) error {
if len(chunkIds) == 0 {
return nil
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(chunkIds)), ",")
args := make([]any, 0, len(chunkIds))
for _, id := range chunkIds {
args = append(args, id)
}
_, err := g.DB(consts.DbGroupDefault).Exec(ctx,
"DELETE FROM "+consts.TableNameKgEntity+" WHERE chunk_id IN ("+placeholders+")", args...)
return err
}
func (d *kgEntityDao) DeleteByDataset(ctx context.Context, datasetId int64) error {
_, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameKgEntity).Ctx(ctx).
Where("dataset_id", datasetId).Delete()
return err
}
// KgEntitySource 实体 → 出现文件清单(dao 内已按名聚合)
type KgEntitySource struct {
Name string
Files []string
}
// SourcesByDataset 实体名 → 出现文件清单(单表约束:拆 4 条单表查询 + 内存组装)。
// 有关系的实体经关系表溯源(关系不去重、每条带来源 chunk,覆盖实体全部出现文件);
// 孤立实体(无任何关系)由实体行 chunk_id 弱引用兜底(该弱引用是 upsert 的最近来源,不保证全部出现文件)。
// 不再建实体-分块关联表:该组合已覆盖"图谱页标注来源文件"的展示需求。
func (d *kgEntityDao) SourcesByDataset(ctx context.Context, datasetId int64) ([]KgEntitySource, error) {
// 1. 关系表(单表):数据集全部三元组的 head/tail 及其来源分块
relRows, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameKgRelation).Ctx(ctx).
Fields("head", "tail", "chunk_id").Where("dataset_id", datasetId).All()
if err != nil {
return nil, err
}
// 2. 实体表(单表):孤立实体弱引用兜底
entRows, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameKgEntity).Ctx(ctx).
Fields("name", "chunk_id").Where("dataset_id", datasetId).All()
if err != nil {
return nil, err
}
// 3. 分块表(单表):数据集全部分块 chunk_id → document_id
chunkRows, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameChunk).Ctx(ctx).
Fields("id", "document_id").Where("dataset_id", datasetId).All()
if err != nil {
return nil, err
}
// 4. 文档表(单表):数据集全部文档 id → 文件名
docRows, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameDocument).Ctx(ctx).
Fields("id", "filename").Where("dataset_id", datasetId).All()
if err != nil {
return nil, err
}
docByID := make(map[int64]string, len(docRows))
for _, row := range docRows {
docByID[row["id"].Int64()] = row["filename"].String()
}
docByChunk := make(map[int64]int64, len(chunkRows))
for _, row := range chunkRows {
docByChunk[row["id"].Int64()] = row["document_id"].Int64()
}
filesByName := make(map[string]map[string]bool)
add := func(name string, chunkId int64) {
docId, ok := docByChunk[chunkId]
if !ok {
return
}
fname := docByID[docId]
if name == "" || fname == "" {
return
}
if filesByName[name] == nil {
filesByName[name] = make(map[string]bool)
}
filesByName[name][fname] = true
}
for _, row := range relRows {
add(row["head"].String(), row["chunk_id"].Int64())
add(row["tail"].String(), row["chunk_id"].Int64())
}
for _, row := range entRows {
add(row["name"].String(), row["chunk_id"].Int64())
}
out := make([]KgEntitySource, 0, len(filesByName))
for name, set := range filesByName {
files := make([]string, 0, len(set))
for f := range set {
files = append(files, f)
}
out = append(out, KgEntitySource{Name: name, Files: files})
}
return out, nil
}