7b1bb1054f
Large scanned PDFs (hundreds of pages) were slow and fragile end-to-end.
This change addresses the parse, transport, and task-scheduling layers:
docreader (parse + transport):
- Parallelize per-page scanned rendering across processes (forkserver/fork),
with serial fallback. ~4-7x faster on large scanned PDFs; pdfium is not
thread-safe so we fan out across processes. Configurable via
DOCREADER_PDF_RENDER_PARALLELISM.
- Add server-streaming ReadStream RPC: emit one meta frame then one frame per
image, so documents with many page images are no longer capped by the unary
gRPC message-size limit (a 874-page PDF produced ~193MiB of images, far over
the 50MB cap) and memory is bounded on both ends. Unary Read is kept for
backward compatibility; the Go production reader switches to ReadStream.
VLM:
- Make the VLM HTTP timeout configurable (VLM_HTTP_TIMEOUT_SECONDS) and raise
the default 90s -> 180s so dense scanned-page OCR does not time out with
"context deadline exceeded".
Async task queues:
- Isolate high-volume, model-heavy fan-out tasks into dedicated asynq queues so
a single large document cannot saturate the shared worker pool and block
user-facing document parsing:
image:multimodal -> "multimodal"
chunk:extract -> "graph"
question:generation -> "question"
- Register the new queues in the server weight map and the cancel inspector's
scanned-queue set (so cancelling a knowledge still purges its pending tasks).
161 lines
4.6 KiB
Go
161 lines
4.6 KiB
Go
package vlm
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"fmt"
|
||
"net/http"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/Tencent/WeKnora/internal/logger"
|
||
"github.com/Tencent/WeKnora/internal/models/provider"
|
||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||
openai "github.com/sashabaranov/go-openai"
|
||
)
|
||
|
||
const (
|
||
// defaultTimeout is the fallback HTTP timeout for a single VLM request.
|
||
// Dense scanned-PDF OCR (full-page text + layout extraction) can take well
|
||
// over a minute on slow endpoints, so this is intentionally generous and
|
||
// can be raised further via VLM_HTTP_TIMEOUT_SECONDS.
|
||
defaultTimeout = 180 * time.Second
|
||
defaultMaxToks = 5000
|
||
defaultTemp = float32(0.1)
|
||
)
|
||
|
||
// vlmHTTPTimeout returns the HTTP client timeout for VLM requests, read from
|
||
// the VLM_HTTP_TIMEOUT_SECONDS env var when set (and positive), falling back to
|
||
// defaultTimeout otherwise. Shared by all OpenAI-compatible VLM backends.
|
||
func vlmHTTPTimeout() time.Duration {
|
||
if v := strings.TrimSpace(os.Getenv("VLM_HTTP_TIMEOUT_SECONDS")); v != "" {
|
||
if secs, err := strconv.Atoi(v); err == nil && secs > 0 {
|
||
return time.Duration(secs) * time.Second
|
||
}
|
||
}
|
||
return defaultTimeout
|
||
}
|
||
|
||
// RemoteAPIVLM implements VLM via an OpenAI-compatible chat completions API.
|
||
type RemoteAPIVLM struct {
|
||
modelName string
|
||
modelID string
|
||
client *openai.Client
|
||
baseURL string
|
||
}
|
||
|
||
// NewRemoteAPIVLM creates a remote-API backed VLM instance.
|
||
func NewRemoteAPIVLM(config *Config) (*RemoteAPIVLM, error) {
|
||
providerName := provider.ProviderName(config.Provider)
|
||
if providerName == "" {
|
||
providerName = provider.DetectProvider(config.BaseURL)
|
||
}
|
||
|
||
var apiCfg openai.ClientConfig
|
||
if providerName == provider.ProviderAzureOpenAI {
|
||
apiCfg = openai.DefaultAzureConfig(config.APIKey, config.BaseURL)
|
||
apiCfg.AzureModelMapperFunc = func(model string) string {
|
||
return model
|
||
}
|
||
if config.Extra != nil {
|
||
if v, ok := config.Extra["api_version"]; ok {
|
||
if vs, ok := v.(string); ok && vs != "" {
|
||
apiCfg.APIVersion = vs
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
apiCfg = openai.DefaultConfig(config.APIKey)
|
||
if config.BaseURL != "" {
|
||
apiCfg.BaseURL = config.BaseURL
|
||
}
|
||
}
|
||
httpClient := &http.Client{Timeout: vlmHTTPTimeout()}
|
||
|
||
// 注入用户自定义 HTTP header(类似 OpenAI Python SDK 的 extra_headers)
|
||
if len(config.CustomHeaders) > 0 {
|
||
apiCfg.HTTPClient = secutils.WrapHTTPClientWithHeaders(httpClient, config.CustomHeaders)
|
||
} else {
|
||
apiCfg.HTTPClient = httpClient
|
||
}
|
||
|
||
return &RemoteAPIVLM{
|
||
modelName: config.ModelName,
|
||
modelID: config.ModelID,
|
||
client: openai.NewClientWithConfig(apiCfg),
|
||
baseURL: config.BaseURL,
|
||
}, nil
|
||
}
|
||
|
||
// Predict sends an image with a text prompt to the OpenAI-compatible API.
|
||
func (v *RemoteAPIVLM) Predict(ctx context.Context, imgBytesList [][]byte, prompt string) (string, error) {
|
||
var parts []openai.ChatMessagePart
|
||
|
||
// Add text prompt first
|
||
parts = append(parts, openai.ChatMessagePart{
|
||
Type: openai.ChatMessagePartTypeText,
|
||
Text: prompt,
|
||
})
|
||
|
||
// Add images
|
||
for _, imgBytes := range imgBytesList {
|
||
if len(imgBytes) > 0 {
|
||
mimeType := detectImageMIME(imgBytes)
|
||
b64 := base64.StdEncoding.EncodeToString(imgBytes)
|
||
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, b64)
|
||
parts = append(parts, openai.ChatMessagePart{
|
||
Type: openai.ChatMessagePartTypeImageURL,
|
||
ImageURL: &openai.ChatMessageImageURL{
|
||
URL: dataURI,
|
||
Detail: openai.ImageURLDetailAuto,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
req := openai.ChatCompletionRequest{
|
||
Model: v.modelName,
|
||
Messages: []openai.ChatCompletionMessage{
|
||
{
|
||
Role: openai.ChatMessageRoleUser,
|
||
MultiContent: parts,
|
||
},
|
||
},
|
||
MaxTokens: defaultMaxToks,
|
||
Temperature: defaultTemp,
|
||
}
|
||
|
||
totalImageSize := 0
|
||
for _, img := range imgBytesList {
|
||
totalImageSize += len(img)
|
||
}
|
||
logger.Infof(ctx, "[VLM] Calling OpenAI-compatible API, model=%s, baseURL=%s, numImages=%d, totalImageSize=%d",
|
||
v.modelName, v.baseURL, len(imgBytesList), totalImageSize)
|
||
|
||
resp, err := v.client.CreateChatCompletion(ctx, req)
|
||
if err != nil {
|
||
return "", fmt.Errorf("OpenAI VLM request: %w", err)
|
||
}
|
||
if len(resp.Choices) == 0 {
|
||
return "", fmt.Errorf("OpenAI VLM returned no choices")
|
||
}
|
||
|
||
content := resp.Choices[0].Message.Content
|
||
logger.Infof(ctx, "[VLM] OpenAI response received, len=%d", len(content))
|
||
return content, nil
|
||
}
|
||
|
||
func (v *RemoteAPIVLM) GetModelName() string { return v.modelName }
|
||
func (v *RemoteAPIVLM) GetModelID() string { return v.modelID }
|
||
|
||
// detectImageMIME returns the MIME type for the given image bytes.
|
||
func detectImageMIME(data []byte) string {
|
||
ct := http.DetectContentType(data)
|
||
if strings.HasPrefix(ct, "image/") {
|
||
return ct
|
||
}
|
||
return "image/png"
|
||
}
|