feat(service): 实现嵌入式多领域知识中枢与Hybrid RRF检索引擎

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-13 02:50:19 +00:00
committed by weichen
co-authored by Cursor
parent 7d123ae8c6
commit 7cbfb33150
15 changed files with 1417 additions and 22 deletions
+60
View File
@@ -0,0 +1,60 @@
package kb
import (
"context"
"fmt"
"strings"
"time"
)
// LookupCite 根据标准号/条文引用粗查法规库;命中则返回可写入参考文献的完整说明条目
// 若未命中或无法连接返回 ok=false
func (r *Retriever) LookupCite(ctx context.Context, citeKey string) (string, bool) {
if r == nil || r.Store == nil || r.Store.Pool == nil {
return "", false
}
std := strings.TrimSpace(citeKey)
std = strings.TrimPrefix(std, "〔")
std = strings.TrimSuffix(std, "〕")
std = strings.TrimPrefix(std, "[")
std = strings.TrimSuffix(std, "]")
clause := ""
if idx := strings.IndexAny(std, " §"); idx >= 0 {
clause = strings.TrimSpace(std[idx:])
std = strings.TrimSpace(std[:idx])
}
if std == "" {
return "", false
}
cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
// 1. 尝试精确查找文档
var title, docNo, status string
err := r.Store.Pool.QueryRow(cctx, `
SELECT COALESCE(doc_no,''), COALESCE(title,''), COALESCE(status,'')
FROM reg_documents
WHERE doc_no ILIKE $1 OR title ILIKE $1
ORDER BY id ASC LIMIT 1`, "%"+std+"%").Scan(&docNo, &title, &status)
if err == nil && docNo != "" {
line := fmt.Sprintf("〔%s%s〕%s", docNo, clause, title)
if status == "repealed" || status == "superseded" {
line = "〔已废止〕" + line
}
return line, true
}
// 2. 备选:按 Search 检索 Top 1
hits, err := r.Search(cctx, std, SearchOptions{Limit: 1, IncludeRepealed: true})
if err == nil && len(hits) > 0 {
h := hits[0]
line := fmt.Sprintf("%s %s", h.CiteKey, h.Title)
return line, true
}
return "", false
}
+101 -11
View File
@@ -3,24 +3,104 @@ package kb
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"chat-agent-system/internal/config"
"github.com/jackc/pgx/v5/pgxpool"
)
// Client 知识库中枢客户端,实现 Hub 接口
type Client struct {
cfg *config.Config
cfg *config.Config
pool *pgxpool.Pool
store *Store
embedder *Embedder
reranker *Reranker
retriever *Retriever
}
// 确保 Client 实现了 Hub 接口
var _ Hub = (*Client)(nil)
// NewClient 创建知识库中枢客户端实例
func NewClient(cfg *config.Config) *Client {
return &Client{cfg: cfg}
c := &Client{
cfg: cfg,
}
if cfg == nil {
return c
}
// 1. 初始化 Embedder
if cfg.EmbeddingEndpointURL != "" {
c.embedder = NewEmbedder(cfg.EmbeddingEndpointURL, cfg.EmbeddingAPIKey, cfg.EmbeddingModel)
}
// 2. 初始化 Reranker
if cfg.RerankerEndpointURL != "" {
c.reranker = NewReranker(cfg.RerankerEndpointURL, cfg.RerankerAPIKey, cfg.RerankerModel)
}
// 3. 尝试建立 PostgreSQL 连接池
dsn := cfg.KbDSN()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
pool, err := OpenPool(ctx, dsn)
if err != nil {
log.Printf("[kb] 无法连接到 PostgreSQL 知识库 (%s): %v, 将降级运行在离线模式", dsn, err)
return c
}
c.pool = pool
c.store = NewStore(pool)
c.retriever = NewRetriever(c.store, c.embedder, c.reranker)
return c
}
func (c *Client) Search(ctx context.Context, query string, limit int) ([]HitItem, error) {
// 基础查询:当未开启或处于离线测试时,返回标准结构
return []HitItem{}, nil
// Search 通用跨领域检索
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ([]HitItem, error) {
if c.retriever == nil {
return []HitItem{}, nil
}
return c.retriever.Search(ctx, query, opts)
}
// SearchSimple 兼容简易调用的检索方法
func (c *Client) SearchSimple(ctx context.Context, query string, limit int) ([]HitItem, error) {
opts := DefaultSearchOptions()
if limit > 0 {
opts.Limit = limit
}
return c.Search(ctx, query, opts)
}
// LookupCite 根据标准号/条文引用粗查知识库
func (c *Client) LookupCite(ctx context.Context, citeKey string) (string, bool) {
if c.retriever == nil {
return "", false
}
return c.retriever.LookupCite(ctx, citeKey)
}
// Stats 查询各知识领域的统计数据
func (c *Client) Stats(ctx context.Context) (map[KnowledgeDomain]Stats, error) {
if c.store == nil {
return make(map[KnowledgeDomain]Stats), nil
}
return c.store.Stats(ctx)
}
// Close 释放连接池资源
func (c *Client) Close() {
if c.pool != nil {
c.pool.Close()
}
}
// MaterializeSnapshot 将检索命中的法规写入任务工作空间的 knowledge/ 目录
@@ -31,13 +111,23 @@ func MaterializeSnapshot(dir string, hits []HitItem) error {
}
var sb strings.Builder
sb.WriteString("# 法规与指导原则检索快照\n\n")
sb.WriteString("以下为针对本项目命中的参考法规标准条文,请结合上下文使用,标注〔出处〕。\n\n")
sb.WriteString("# 权威法规与技术标准引用库 (Regulatory Knowledge Hits)\n\n")
sb.WriteString("以下为针对本项目研发与注册需求命中的权威法规、国家标准(GB)与行业标准(YY)条款。\n")
sb.WriteString("在输出交付物时,请结合上下文使用,并显式标注【引用标记 (CiteKey)】标明出处。\n\n")
for _, h := range hits {
sb.WriteString(fmt.Sprintf("### [%s] %s %s\n", strings.ToUpper(h.Category), h.DocCode, h.Title))
sb.WriteString(h.Content + "\n\n")
sb.WriteString(fmt.Sprintf("> 来源: %s\n\n---\n\n", h.Source))
for i, h := range hits {
sb.WriteString(fmt.Sprintf("### %d. [%s] %s\n", i+1, h.DocCode, h.Title))
if h.Category != "" {
sb.WriteString(fmt.Sprintf("- **类别**: %s\n", h.Category))
}
if h.HeadingPath != "" {
sb.WriteString(fmt.Sprintf("- **章节路径**: %s\n", h.HeadingPath))
}
if h.CiteKey != "" {
sb.WriteString(fmt.Sprintf("- **引用标记 (CiteKey)**: `%s`\n", h.CiteKey))
}
sb.WriteString(fmt.Sprintf("- **来源**: %s\n\n", h.Source))
sb.WriteString(h.Content + "\n\n---\n\n")
}
return os.WriteFile(filepath.Join(kbDir, "hits.md"), []byte(sb.String()), 0o644)
+311
View File
@@ -0,0 +1,311 @@
package kb
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"unicode/utf8"
)
const (
// EmbeddingDim BGE-M3 标准向量维度
EmbeddingDim = 1024
// embedRequestTokenBudget 单批次 Token 上限
embedRequestTokenBudget = 7000
// embedMaxBatchItems 单请求文本条数上限
embedMaxBatchItems = 16
// embedMaxItemTokens 单条文本截断上限
embedMaxItemTokens = 6000
// embedLogBodyMax 错误截断最大字节
embedLogBodyMax = 2048
)
// Embedder 向量生成客户端
type Embedder struct {
Endpoint string
APIKey string
Model string
HTTP *http.Client
}
// NewEmbedder 创建 Embedder 实例
func NewEmbedder(endpoint, key, model string) *Embedder {
return &Embedder{
Endpoint: normalizeAPIEndpoint(endpoint, "/embeddings"),
APIKey: key,
Model: model,
HTTP: &http.Client{Timeout: 60 * time.Second},
}
}
// Embed 批量生成文本嵌入向量
func (e *Embedder) Embed(ctx context.Context, texts []string) ([][]float32, error) {
if e == nil || e.Endpoint == "" {
return nil, fmt.Errorf("embedder not configured")
}
if len(texts) == 0 {
return nil, nil
}
out := make([][]float32, len(texts))
for start := 0; start < len(texts); {
end := nextEmbedBatchEnd(texts, start, embedRequestTokenBudget, embedMaxBatchItems)
batch := texts[start:end]
vecs, err := e.embedBatch(ctx, batch)
if err != nil {
if isContextLengthError(err) && len(batch) > 1 {
mid := start + (end-start+1)/2
if mid <= start {
mid = start + 1
}
if mid >= end {
mid = end - 1
}
left, lerr := e.Embed(ctx, texts[start:mid])
if lerr != nil {
return nil, lerr
}
right, rerr := e.Embed(ctx, texts[mid:end])
if rerr != nil {
return nil, rerr
}
copy(out[start:mid], left)
copy(out[mid:end], right)
start = end
continue
}
est := estimateTokensBatch(batch)
return nil, fmt.Errorf("%w (batch=%d est_tokens≈%d)", err, len(batch), est)
}
copy(out[start:end], vecs)
start = end
}
return out, nil
}
// EmbedOne 单条文本向量化辅助方法
func (e *Embedder) EmbedOne(ctx context.Context, text string) ([]float32, error) {
vecs, err := e.Embed(ctx, []string{text})
if err != nil {
return nil, err
}
if len(vecs) == 0 {
return nil, fmt.Errorf("empty embedding returned")
}
return vecs[0], nil
}
func (e *Embedder) embedBatch(ctx context.Context, texts []string) ([][]float32, error) {
body := map[string]any{"model": e.Model, "input": texts}
raw, err := e.post(ctx, e.Endpoint, body)
if err != nil {
return nil, err
}
var resp struct {
Data []struct {
Embedding []float32 `json:"embedding"`
Index int `json:"index"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &resp); err != nil {
return nil, fmt.Errorf("decode embeddings: %s", formatHTTPBodyError(0, raw))
}
out := make([][]float32, len(texts))
for _, d := range resp.Data {
if d.Index >= 0 && d.Index < len(out) {
out[d.Index] = padOrTrim(d.Embedding, EmbeddingDim)
}
}
for i, v := range out {
if v == nil && i < len(resp.Data) {
out[i] = padOrTrim(resp.Data[i].Embedding, EmbeddingDim)
}
if out[i] == nil {
return nil, fmt.Errorf("missing embedding %d", i)
}
}
return out, nil
}
func (e *Embedder) post(ctx context.Context, url string, body any) ([]byte, error) {
return postJSON(ctx, e.HTTP, url, e.APIKey, body)
}
func postJSON(ctx context.Context, httpc *http.Client, url, key string, body any) ([]byte, error) {
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
resp, err := httpc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("http %s: %s", resp.Status, formatHTTPBodyError(resp.StatusCode, raw))
}
return raw, nil
}
func formatHTTPBodyError(status int, raw []byte) string {
msg := extractAPIErrorMessage(raw)
if msg != "" {
return msg
}
trim := strings.TrimSpace(string(raw))
if trim == "" {
if status > 0 {
return fmt.Sprintf("empty body (status %d)", status)
}
return "empty body"
}
if !json.Valid(raw) || strings.HasPrefix(trim, "<") {
preview := truncateUTF8(trim, 200)
if status > 0 {
return fmt.Sprintf("non-JSON response (status %d): %s", status, preview)
}
return "non-JSON response: " + preview
}
return truncateUTF8(trim, embedLogBodyMax)
}
func extractAPIErrorMessage(raw []byte) string {
var envelope struct {
Error struct {
Message string `json:"message"`
Type string `json:"type"`
Code any `json:"code"`
} `json:"error"`
Message string `json:"message"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return ""
}
if m := strings.TrimSpace(envelope.Error.Message); m != "" {
return m
}
return strings.TrimSpace(envelope.Message)
}
func isContextLengthError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "maximum context length") ||
strings.Contains(msg, "context length") ||
strings.Contains(msg, "too many tokens") ||
(strings.Contains(msg, "input tokens") && strings.Contains(msg, "reduce"))
}
func estimateTokens(s string) int {
s = strings.TrimSpace(s)
if s == "" {
return 1
}
tokens := 0
asciiRun := 0
flushASCII := func() {
if asciiRun == 0 {
return
}
tokens += (asciiRun + 3) / 4
asciiRun = 0
}
for _, r := range s {
if r <= 127 {
asciiRun++
continue
}
flushASCII()
tokens += 2
}
flushASCII()
if tokens < 1 {
return 1
}
return tokens
}
func estimateTokensBatch(texts []string) int {
sum := 0
for _, t := range texts {
sum += estimateTokens(t)
}
return sum
}
func nextEmbedBatchEnd(texts []string, start, budget, maxItems int) int {
if maxItems <= 0 {
maxItems = embedMaxBatchItems
}
end := start
used := 0
for end < len(texts) && end-start < maxItems {
t := estimateTokens(texts[end])
if t > embedMaxItemTokens {
t = embedMaxItemTokens
}
if end > start && used+t > budget {
break
}
used += t
end++
if used >= budget {
break
}
}
if end == start {
end = start + 1
}
return end
}
func normalizeAPIEndpoint(endpoint, suffix string) string {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return ""
}
endpoint = strings.TrimRight(endpoint, "/")
if strings.HasSuffix(endpoint, suffix) {
return endpoint
}
if strings.HasSuffix(endpoint, "/v1") || strings.Contains(endpoint, "/v1/") {
return endpoint + suffix
}
return endpoint + "/v1" + suffix
}
func padOrTrim(v []float32, n int) []float32 {
if len(v) == n {
return v
}
out := make([]float32, n)
copy(out, v)
return out
}
func truncateUTF8(s string, n int) string {
if n <= 0 || len(s) <= n {
return s
}
for n > 0 && !utf8.RuneStart(s[n]) {
n--
}
if n <= 0 {
return ""
}
return s[:n]
}
+57
View File
@@ -0,0 +1,57 @@
package kb_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"chat-agent-system/internal/kb"
)
func TestEmbedder_MockServer(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/embeddings" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
var req struct {
Model string `json:"model"`
Input []string `json:"input"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
type item struct {
Embedding []float32 `json:"embedding"`
Index int `json:"index"`
}
var data []item
for i := range req.Input {
vec := make([]float32, kb.EmbeddingDim)
vec[0] = float32(i + 1)
data = append(data, item{
Embedding: vec,
Index: i,
})
}
resp := map[string]any{"data": data}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
embedder := kb.NewEmbedder(server.URL, "test-key", "bge-m3")
vec, err := embedder.EmbedOne(context.Background(), "医用电气安全")
if err != nil {
t.Fatalf("EmbedOne failed: %v", err)
}
if len(vec) != kb.EmbeddingDim {
t.Fatalf("expected dim %d, got %d", kb.EmbeddingDim, len(vec))
}
if vec[0] != 1.0 {
t.Fatalf("expected vec[0]=1.0, got %f", vec[0])
}
}
+21
View File
@@ -0,0 +1,21 @@
package kb
import (
"context"
)
// Hub 多领域知识中枢统一服务契约
// 支持医疗器械法规标准、历史项目数字资产、员工能力模型等统一或分域检索
type Hub interface {
// Search 通用跨领域检索(支持按 SearchOptions 过滤领域、分类与分数)
Search(ctx context.Context, query string, opts SearchOptions) ([]HitItem, error)
// LookupCite 根据精准引用键 (如 "[GB 9706.1-2020 §8.4]") 定位条文
LookupCite(ctx context.Context, citeKey string) (string, bool)
// Stats 获取各知识领域的数据规模与资产统计
Stats(ctx context.Context) (map[KnowledgeDomain]Stats, error)
// Close 释放数据库连接池等资源
Close()
}
+85
View File
@@ -0,0 +1,85 @@
package kb_test
import (
"context"
"strings"
"testing"
"time"
"chat-agent-system/internal/config"
"chat-agent-system/internal/kb"
)
func TestKB_RealIntegration(t *testing.T) {
cfg, err := config.Load("")
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
// 强制使用本机开发环境共享知识库容器端口
if cfg.KbDbPort == 0 {
cfg.KbDbPort = 5432
}
client := kb.NewClient(cfg)
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// 1. 验证多领域资产规模统计
t.Run("Stats", func(t *testing.T) {
stats, err := client.Stats(ctx)
if err != nil {
t.Fatalf("client.Stats failed: %v", err)
}
regStats, ok := stats[kb.DomainRegulatory]
if !ok {
t.Fatalf("expected regulatory domain in stats")
}
if regStats.DocumentCount != 2907 {
t.Errorf("expected 2907 documents, got %d", regStats.DocumentCount)
}
if regStats.ChunkCount < 312900 {
t.Errorf("expected >= 312900 chunks, got %d", regStats.ChunkCount)
}
t.Logf("Stats verified: %d docs, %d chunks", regStats.DocumentCount, regStats.ChunkCount)
})
// 2. 验证关键法规标准精确与模糊检索 (GB 9706.1 医用电气设备安全通用要求)
t.Run("Search_GB9706", func(t *testing.T) {
hits, err := client.Search(ctx, "GB 9706.1 医用电气设备 基本安全", kb.SearchOptions{
Limit: 3,
CandidateLimit: 10,
})
if err != nil {
t.Fatalf("client.Search failed: %v", err)
}
if len(hits) == 0 {
t.Fatalf("expected hits for GB 9706.1, got 0")
}
found := false
for _, h := range hits {
t.Logf("Hit: [%s] %s (Cite: %s, Score: %f)", h.DocCode, h.Title, h.CiteKey, h.Score)
if strings.Contains(h.DocCode, "9706") || strings.Contains(h.Title, "9706") || strings.Contains(h.Title, "电气安全") {
found = true
}
}
if !found {
t.Errorf("expected at least one hit containing '9706' or '电气安全'")
}
})
// 3. 验证引用规范精准定位 (LookupCite)
t.Run("LookupCite", func(t *testing.T) {
line, ok := client.LookupCite(ctx, "GB 9706.1-2020 §8.4")
if !ok {
t.Fatalf("LookupCite failed to find GB 9706.1-2020 §8.4")
}
t.Logf("LookupCite result: %s", line)
if !strings.Contains(line, "9706.1") {
t.Errorf("expected line to contain 9706.1, got: %s", line)
}
})
}
-9
View File
@@ -1,9 +0,0 @@
package kb
type HitItem struct {
Category string `json:"category"` // standards, guidance, laws, catalog
DocCode string `json:"doc_code"`
Title string `json:"title"`
Content string `json:"content"`
Source string `json:"source"`
}
+73
View File
@@ -0,0 +1,73 @@
package kb
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// Reranker 语义重排客户端 (Cross-Encoder)
type Reranker struct {
Endpoint string
APIKey string
Model string
HTTP *http.Client
}
// NewReranker 创建 Reranker 实例
func NewReranker(endpoint, key, model string) *Reranker {
return &Reranker{
Endpoint: normalizeAPIEndpoint(endpoint, "/rerank"),
APIKey: key,
Model: model,
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
// Rerank 对候选文档列表依据查询进行相关性重排打分
// 返回与 docs 等长的一组打分,下标一一对应
func (r *Reranker) Rerank(ctx context.Context, query string, docs []string) ([]float64, error) {
if r == nil || r.Endpoint == "" {
return nil, fmt.Errorf("reranker not configured")
}
if len(docs) == 0 {
return []float64{}, nil
}
body := map[string]any{
"model": r.Model,
"query": query,
"documents": docs,
}
raw, err := postJSON(ctx, r.HTTP, r.Endpoint, r.APIKey, body)
if err != nil {
return nil, err
}
var resp struct {
Results []struct {
Index int `json:"index"`
RelevanceScore float64 `json:"relevance_score"`
Score float64 `json:"score"`
} `json:"results"`
}
if err := json.Unmarshal(raw, &resp); err != nil {
return nil, fmt.Errorf("decode rerank: %s", formatHTTPBodyError(0, raw))
}
out := make([]float64, len(docs))
for _, it := range resp.Results {
s := it.RelevanceScore
if s == 0 {
s = it.Score
}
if it.Index >= 0 && it.Index < len(out) {
out[it.Index] = s
}
}
return out, nil
}
+60
View File
@@ -0,0 +1,60 @@
package kb_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"chat-agent-system/internal/kb"
)
func TestReranker_MockServer(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/rerank" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
var req struct {
Model string `json:"model"`
Query string `json:"query"`
Documents []string `json:"documents"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
type result struct {
Index int `json:"index"`
RelevanceScore float64 `json:"relevance_score"`
}
var results []result
for i := range req.Documents {
results = append(results, result{
Index: i,
RelevanceScore: 0.95 - float64(i)*0.1,
})
}
resp := map[string]any{"results": results}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
reranker := kb.NewReranker(server.URL, "test-key", "bge-reranker-v2-m3")
docs := []string{
"GB 9706.1-2020 医用电气设备安全通用要求",
"GB/T 4208 外壳防护等级",
}
scores, err := reranker.Rerank(context.Background(), "医用电气安全接地", docs)
if err != nil {
t.Fatalf("Rerank failed: %v", err)
}
if len(scores) != len(docs) {
t.Fatalf("expected %d scores, got %d", len(docs), len(scores))
}
if scores[0] <= scores[1] {
t.Errorf("expected doc 0 score > doc 1 score, got %f vs %f", scores[0], scores[1])
}
}
+348
View File
@@ -0,0 +1,348 @@
package kb
import (
"context"
"database/sql"
"fmt"
"log"
"sort"
"strconv"
"strings"
)
// Retriever 检索调度器
type Retriever struct {
Store *Store
Embedder *Embedder
Reranker *Reranker
}
// NewRetriever 创建检索调度器
func NewRetriever(store *Store, embedder *Embedder, reranker *Reranker) *Retriever {
return &Retriever{
Store: store,
Embedder: embedder,
Reranker: reranker,
}
}
// Search 执行跨领域语义检索与重排
func (r *Retriever) Search(ctx context.Context, query string, opts SearchOptions) ([]HitItem, error) {
q := strings.TrimSpace(query)
if q == "" {
return []HitItem{}, nil
}
if opts.Limit <= 0 {
opts.Limit = 5
}
if opts.CandidateLimit <= 0 {
opts.CandidateLimit = 20
}
if len(opts.Domains) == 0 {
opts.Domains = []KnowledgeDomain{DomainRegulatory}
}
var allCandidates []HitItem
for _, domain := range opts.Domains {
switch domain {
case DomainRegulatory:
hits, err := r.searchRegulatory(ctx, q, opts)
if err != nil {
log.Printf("[kb-retriever] searchRegulatory err: %v", err)
} else {
allCandidates = append(allCandidates, hits...)
}
case DomainProjectAsset:
hits, err := r.searchProjectAsset(ctx, q, opts)
if err != nil {
log.Printf("[kb-retriever] searchProjectAsset err: %v", err)
} else {
allCandidates = append(allCandidates, hits...)
}
}
}
if len(allCandidates) == 0 {
return []HitItem{}, nil
}
// 排序候选集
sort.Slice(allCandidates, func(i, j int) bool {
return allCandidates[i].Score > allCandidates[j].Score
})
// 二阶段语义重排 (Cross-Encoder)
if r.Reranker != nil && r.Reranker.Endpoint != "" && len(allCandidates) > 1 {
rerankLimit := opts.CandidateLimit
if len(allCandidates) < rerankLimit {
rerankLimit = len(allCandidates)
}
topList := allCandidates[:rerankLimit]
docs := make([]string, len(topList))
for i, h := range topList {
docs[i] = firstNRunes(h.DocCode+" "+h.Title+"\n"+h.HeadingPath+"\n"+h.Content, 720)
}
scores, err := r.Reranker.Rerank(ctx, q, docs)
if err != nil {
log.Printf("[kb-retriever] reranker failed (keeping RRF scores): %v", err)
} else if len(scores) == len(topList) {
for i := range topList {
topList[i].Score = scores[i]
}
sort.Slice(topList, func(i, j int) bool {
return topList[i].Score > topList[j].Score
})
allCandidates = topList
}
}
// 过滤分数与截断
var results []HitItem
for _, it := range allCandidates {
if opts.MinScore > 0 && it.Score < opts.MinScore {
continue
}
results = append(results, it)
if len(results) >= opts.Limit {
break
}
}
return results, nil
}
// searchRegulatory 法规领域检索实现 (Vector + Keyword + RRF)
func (r *Retriever) searchRegulatory(ctx context.Context, q string, opts SearchOptions) ([]HitItem, error) {
if r.Store == nil || r.Store.Pool == nil {
return nil, fmt.Errorf("database not connected")
}
var vecHits []HitItem
if r.Embedder != nil && r.Embedder.Endpoint != "" {
vec, err := r.Embedder.EmbedOne(ctx, q)
if err != nil {
log.Printf("[kb-retriever] embed failed: %v", err)
} else if len(vec) == EmbeddingDim {
vecHits, err = r.regVectorHits(ctx, vec, opts.IncludeRepealed, 40)
if err != nil {
log.Printf("[kb-retriever] regVectorHits failed: %v", err)
}
}
}
kwHits, err := r.regKeywordHits(ctx, q, opts.IncludeRepealed, 30)
if err != nil {
log.Printf("[kb-retriever] regKeywordHits failed: %v", err)
}
// Reciprocal Rank Fusion (k=60.0)
const rrfK = 60.0
type rrfItem struct {
hit HitItem
score float64
}
fused := make(map[string]*rrfItem)
for rank, h := range vecHits {
score := 1.0 / (rrfK + float64(rank+1))
key := fmt.Sprintf("%s|%s", h.DocCode, h.HeadingPath)
if existing, ok := fused[key]; ok {
existing.score += score
} else {
fused[key] = &rrfItem{hit: h, score: score}
}
}
for rank, h := range kwHits {
score := 1.0 / (rrfK + float64(rank+1))
key := fmt.Sprintf("%s|%s", h.DocCode, h.HeadingPath)
if existing, ok := fused[key]; ok {
existing.score += score
} else {
fused[key] = &rrfItem{hit: h, score: score}
}
}
out := make([]HitItem, 0, len(fused))
for _, it := range fused {
it.hit.Score = it.score
out = append(out, it.hit)
}
return out, nil
}
func (r *Retriever) regVectorHits(ctx context.Context, vec []float32, includeRepealed bool, limit int) ([]HitItem, error) {
lit := VectorLiteral(vec)
sqlStr := `
SELECT c.id, COALESCE(d.doc_no,''), COALESCE(d.title,''), COALESCE(d.kind,'unknown'), COALESCE(d.status,'unknown'),
COALESCE(c.content,''), COALESCE(c.heading_path,''), COALESCE(c.page,0), COALESCE(c.chunk_no,''),
1 - (c.embedding <=> $1::vector) AS score
FROM reg_chunks c
JOIN reg_documents d ON d.id=c.document_id
WHERE c.embedding IS NOT NULL` + regStatusFilter(includeRepealed) + `
ORDER BY c.embedding <=> $1::vector
LIMIT $2`
rows, err := r.Store.Pool.Query(ctx, sqlStr, lit, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRegHits(rows)
}
func (r *Retriever) regKeywordHits(ctx context.Context, q string, includeRepealed bool, limit int) ([]HitItem, error) {
like1 := "%" + escapeLike(q) + "%"
compactQ := strings.ReplaceAll(q, " ", "")
like2 := "%" + escapeLike(compactQ) + "%"
sqlStr := `
SELECT c.id, COALESCE(d.doc_no,''), COALESCE(d.title,''), COALESCE(d.kind,'unknown'), COALESCE(d.status,'unknown'),
COALESCE(c.content,''), COALESCE(c.heading_path,''), COALESCE(c.page,0), COALESCE(c.chunk_no,''),
CASE WHEN c.tsv @@ plainto_tsquery('simple', $1) THEN ts_rank_cd(c.tsv, plainto_tsquery('simple', $1)) ELSE 0.1 END AS score
FROM reg_chunks c
JOIN reg_documents d ON d.id=c.document_id
WHERE (
(c.tsv IS NOT NULL AND c.tsv @@ plainto_tsquery('simple', $1)) OR
COALESCE(d.doc_no,'') ILIKE $2 ESCAPE '\' OR COALESCE(d.doc_no,'') ILIKE $3 ESCAPE '\' OR
COALESCE(c.heading_path,'') ILIKE $2 ESCAPE '\' OR COALESCE(c.heading_path,'') ILIKE $3 ESCAPE '\' OR
COALESCE(c.content,'') ILIKE $2 ESCAPE '\' OR COALESCE(c.content,'') ILIKE $3 ESCAPE '\' OR
COALESCE(c.cite_key,'') ILIKE $2 ESCAPE '\' OR COALESCE(c.cite_key,'') ILIKE $3 ESCAPE '\'
)` + regStatusFilter(includeRepealed) + `
ORDER BY score DESC
LIMIT $4`
rows, err := r.Store.Pool.Query(ctx, sqlStr, q, like1, like2, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRegHits(rows)
}
func (r *Retriever) searchProjectAsset(ctx context.Context, q string, opts SearchOptions) ([]HitItem, error) {
sqlStr := `
SELECT c.id, p.project_code, p.name, d.doc_type, 'active',
c.content, c.chunk_title, 0, '',
CASE WHEN c.tsv @@ plainto_tsquery('simple', $1) THEN ts_rank_cd(c.tsv, plainto_tsquery('simple', $1)) ELSE 0.1 END AS score
FROM asset_chunks c
JOIN asset_projects p ON p.id=c.project_id
JOIN asset_documents d ON d.id=c.document_id
WHERE c.tsv IS NOT NULL AND c.tsv @@ plainto_tsquery('simple', $1)
ORDER BY score DESC LIMIT $2`
rows, err := r.Store.Pool.Query(ctx, sqlStr, q, opts.Limit)
if err != nil {
return nil, nil // 预留表空查询容错
}
defer rows.Close()
var out []HitItem
for rows.Next() {
var id int64
var code, title, category, status, content, heading, chunkNo string
var page int
var score float64
if err := rows.Scan(&id, &code, &title, &category, &status, &content, &heading, &page, &chunkNo, &score); err != nil {
continue
}
out = append(out, HitItem{
Domain: DomainProjectAsset,
Category: category,
DocCode: code,
Title: title,
HeadingPath: heading,
Content: content,
Score: score,
CiteKey: fmt.Sprintf("〔项目:%s §%s〕", code, heading),
Source: "项目数字资产库",
})
}
return out, nil
}
func regStatusFilter(includeRepealed bool) string {
if includeRepealed {
return ""
}
return ` AND LOWER(d.status) IN ('in_force','unknown','active','published')`
}
func scanRegHits(rows interface {
Next() bool
Scan(dest ...any) error
Err() error
}) ([]HitItem, error) {
var out []HitItem
for rows.Next() {
var id int64
var docNo, title, kind, status, content, heading, chunkNo sql.NullString
var page sql.NullInt64
var score sql.NullFloat64
if err := rows.Scan(&id, &docNo, &title, &kind, &status, &content, &heading, &page, &chunkNo, &score); err != nil {
return nil, err
}
docCode := docNo.String
if docCode == "" {
docCode = title.String
}
cite := formatCiteKey(docCode, heading.String, chunkNo.String, int(page.Int64), status.String)
out = append(out, HitItem{
Domain: DomainRegulatory,
Category: kind.String,
DocCode: docCode,
Title: title.String,
HeadingPath: heading.String,
Content: content.String,
Score: score.Float64,
CiteKey: cite,
Source: "国家医疗器械法规与技术标准库",
})
}
return out, rows.Err()
}
func formatCiteKey(docCode, heading, chunkNo string, page int, status string) string {
clause := chunkNo
if clause == "" {
clause = heading
}
pageStr := ""
if page > 0 {
pageStr = " p." + strconv.Itoa(page)
}
clauseStr := ""
if clause != "" {
clauseStr = " §" + clause
}
core := "〔" + docCode + clauseStr + pageStr + "〕"
if status == "repealed" || status == "superseded" {
core = "〔已废止〕" + core
}
return core
}
func escapeLike(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `%`, `\%`)
s = strings.ReplaceAll(s, `_`, `\_`)
return s
}
func firstNRunes(s string, n int) string {
if n <= 0 || len(s) == 0 {
return ""
}
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n])
}
+87
View File
@@ -0,0 +1,87 @@
package kb
import (
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store PostgreSQL 知识库存储访问层
type Store struct {
Pool *pgxpool.Pool
}
// NewStore 创建 Store 实例
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{Pool: pool}
}
// OpenPool 建立 PostgreSQL pgx 连接池
func OpenPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
if strings.TrimSpace(dsn) == "" {
return nil, fmt.Errorf("empty postgres dsn")
}
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, err
}
cfg.MaxConns = 8
cfg.MinConns = 1
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, err
}
return pool, nil
}
// Stats 查询各知识领域的数据行数与切片规模
func (s *Store) Stats(ctx context.Context) (map[KnowledgeDomain]Stats, error) {
out := make(map[KnowledgeDomain]Stats)
if s == nil || s.Pool == nil {
return out, fmt.Errorf("store not connected")
}
// 1. 法规领域统计
var regStats Stats
_ = s.Pool.QueryRow(ctx, `SELECT count(*) FROM reg_documents`).Scan(&regStats.DocumentCount)
_ = s.Pool.QueryRow(ctx, `SELECT count(*) FROM reg_nodes`).Scan(&regStats.NodeCount)
_ = s.Pool.QueryRow(ctx, `SELECT count(*) FROM reg_chunks`).Scan(&regStats.ChunkCount)
out[DomainRegulatory] = regStats
// 2. 项目数字资产领域统计
var assetStats Stats
_ = s.Pool.QueryRow(ctx, `SELECT count(*) FROM asset_projects`).Scan(&assetStats.DocumentCount)
_ = s.Pool.QueryRow(ctx, `SELECT count(*) FROM asset_chunks`).Scan(&assetStats.ChunkCount)
out[DomainProjectAsset] = assetStats
// 3. 员工能力领域统计
var staffStats Stats
_ = s.Pool.QueryRow(ctx, `SELECT count(*) FROM staff_profiles`).Scan(&staffStats.DocumentCount)
_ = s.Pool.QueryRow(ctx, `SELECT count(*) FROM staff_experience_records`).Scan(&staffStats.ChunkCount)
out[DomainStaffCapability] = staffStats
return out, nil
}
// VectorLiteral 将 float32 向量序列化为 pgvector 接受的字面量字符串 [0.1,0.2,...]
func VectorLiteral(vec []float32) string {
if len(vec) == 0 {
return ""
}
var sb strings.Builder
sb.WriteByte('[')
for i, f := range vec {
if i > 0 {
sb.WriteByte(',')
}
sb.WriteString(fmt.Sprintf("%g", f))
}
sb.WriteByte(']')
return sb.String()
}
+111
View File
@@ -0,0 +1,111 @@
package kb
import (
"time"
)
// KnowledgeDomain 知识领域类型
type KnowledgeDomain string
const (
// DomainRegulatory 医疗器械法律法规、国家标准(GB/T)、行业标准(YY)与指导原则
DomainRegulatory KnowledgeDomain = "regulatory"
// DomainProjectAsset 已完成历史项目数字资产(可行性报告、CDO 报价、方案文档、BOM清单)
DomainProjectAsset KnowledgeDomain = "project_asset"
// DomainStaffCapability 研发团队人员工作能力(技术专长、历史人效系数、工时评估模型)
DomainStaffCapability KnowledgeDomain = "staff_capability"
)
// HitItem 统一跨领域知识检索命中项
type HitItem struct {
Domain KnowledgeDomain `json:"domain"`
Category string `json:"category"` // standards, guidance, laws, catalog, project_doc, staff_model
DocCode string `json:"doc_code"` // 如 GB 9706.1-2020, YY 0569-2019, PRJ-2025-001
Title string `json:"title"` // 文档或标准全称
HeadingPath string `json:"heading_path"` // 章节小节层级路径 (如: 8. 电气安全 > 8.4 接地)
Content string `json:"content"` // 命中正文或条款详细描述
Score float64 `json:"score"` // 综合匹配评分 (Reranker 或 RRF 分数)
CiteKey string `json:"cite_key"` // 权威引用键 (如: [GB 9706.1-2020 §8.4])
Source string `json:"source"` // 来源信息
Metadata map[string]any `json:"metadata,omitempty"`
}
// SearchOptions 检索配置选项
type SearchOptions struct {
Domains []KnowledgeDomain `json:"domains,omitempty"` // 目标领域范围 (空表示默认法规域)
Limit int `json:"limit,omitempty"` // 最终期望返回数量
CandidateLimit int `json:"candidate_limit,omitempty"` // 初筛召回数量 (Rerank 前)
IncludeRepealed bool `json:"include_repealed,omitempty"` // 是否包含已废止/历史版本标准
FilterCategory string `json:"filter_category,omitempty"` // 过滤分类
MinScore float64 `json:"min_score,omitempty"` // 最低分数阈值
}
// DefaultSearchOptions 返回通用检索默认配置
func DefaultSearchOptions() SearchOptions {
return SearchOptions{
Domains: []KnowledgeDomain{DomainRegulatory},
Limit: 5,
CandidateLimit: 20,
IncludeRepealed: false,
MinScore: 0.0,
}
}
// Document 数据库文档实体
type Document struct {
ID int64 `json:"id"`
DocID string `json:"doc_id"`
SHA256 string `json:"sha256"`
FilePath string `json:"file_path"`
FileName string `json:"file_name"`
Kind string `json:"kind"`
DocNo string `json:"doc_no"`
Title string `json:"title"`
Jurisdiction string `json:"jurisdiction"`
Status string `json:"status"`
EffectiveDate *time.Time `json:"effective_date,omitempty"`
RepealDate *time.Time `json:"repeal_date,omitempty"`
SourceURL string `json:"source_url"`
PageCount int `json:"page_count"`
ExtraJSON map[string]any `json:"extra_json,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Node 结构化大纲节点实体
type Node struct {
ID int64 `json:"id"`
DocumentID int64 `json:"document_id"`
ParentID *int64 `json:"parent_id,omitempty"`
Level int `json:"level"`
NodeKind string `json:"node_kind"`
SectionNo string `json:"section_no"`
Title string `json:"title"`
HeadingPath string `json:"heading_path"`
Content string `json:"content"`
PageStart int `json:"page_start"`
PageEnd int `json:"page_end"`
SortOrder int `json:"sort_order"`
}
// Chunk 向量切片实体
type Chunk struct {
ID int64 `json:"id"`
DocumentID int64 `json:"document_id"`
NodeID int64 `json:"node_id"`
Grain string `json:"grain"`
ChunkNo string `json:"chunk_no"`
Content string `json:"content"`
HeadingPath string `json:"heading_path"`
CiteKey string `json:"cite_key"`
Page int `json:"page"`
Embedding []float32 `json:"-"`
MetadataJSON map[string]any `json:"metadata_json,omitempty"`
}
// Stats 统计信息
type Stats struct {
DocumentCount int64 `json:"document_count"`
NodeCount int64 `json:"node_count"`
ChunkCount int64 `json:"chunk_count"`
}