
【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址https://gitcode.com/GitHub_Trending/fr/freehire点击查看免费下载本文以 internal/ai/embed/AGENTS.md 为骨架系统拆解 freehire 的增量语义嵌入管线cmd/embed如何借助semantic_outbox队列找到缺向量/向量过期/需要清理的职位如何用 TEI 把职位描述分块嵌入并写入 pgvector 的job_semantic_chunks以及这条链路在事务边界、失败重试与并发一致性上的取舍。读完后你应能独立配置、调度、排障这条管线并理解它为何唯一剩余效果只是一次 Postgres 写入。一、管线的范围与定位它已经不写搜索索引了1.1 当前职责只剩一件事internal/ai/embed的定位是通过semantic_outbox队列做增量语义嵌入与 enrichmentcmd/enrich管线同构。它当前唯一的效果是一个 Postgres 写入目标job_semantic_chunkspgvector 支撑的分块向量表cmd/similar-backfill实际读取的就是这张表遗留的jobs.semantic_embeddingreal[]列不再被本管线写入只在关闭职位的清理路径上被清空永不填充过去由本包构建并查询的jobs_semanticMeilisearch 索引以及reindex --semantic/--from-pg/--posted-within这组对账入口已在openspec/changes/drop-hybrid-search-pgvector-similar中删除/similar改为读取预计算的 pgvector 结果internal/search/similarjobs、cmd/similar-backfill/main.go。从源码可以确认这条瘦身入口 cmd/embed/main.go 的注释明确写着本 worker 不再需要MEILI_MASTER_KEYsearch.NewClient仍然按位置接收cfg.MeiliURL/cfg.MeiliKey纯粹是为了让所有调用方共用一个构造函数形状此处没有任何 I/O 会被这两个值驱动。1.2 遗留列的处置逻辑为什么只清不写AGENTS.md 记录了一次明确的设计反转Meili 索引移除后曾短暂保留继续写jobs.semantic_embedding的做法随后被回滚。理由是该列已经没有任何读取方它历史上唯一的读者reindex --from-pg已在同一次变更中删除继续计算它等于每个职位白付一次额外的 TEI 调用。而设计文档design.md的迁移计划第 10 步只是把删除该列这个 schema 变更推迟到后续独立变更涉及真实的迁移/部署顺序风险从未要求继续写入它——写入与否是纯代码层面的决策没有这种风险。因此当前现实是列仍留在 schema 中等后续变更删除打开路径完全不触碰它关闭路径由 migrations/0004_semantic_outbox.sql 起就存在的ClearSemanticEmbeddedBatch顺手置 NULL代价极低还能顺带清掉历史遗留值省掉一次一次性回填。二、端到端数据流整条链路的形状可以用一句话概括入队幂等谓词 → 认领租约 SKIP LOCKED → 分支open 嵌入 / closed 清理 → 单事务提交 → 分块写入时触发器把职位重新推入搜索队列。jobs 表 │ EnqueuePendingSemanticJobs幂等 SELECT ... ON CONFLICT DO NOTHING ▼ semantic_outbox (job_id, target_model, job_posted_at, claimed_at, attempts, failed_at, last_error) │ ClaimSemanticBatch租约 FOR UPDATE OF o SKIP LOCKED返回 closed 标志 ▼ Runner.processWave ──┬─ open → Store.Jobs → Indexer.IndexOpen(TEI) → Store.CompleteOpen单事务 └─ closed → Store.CompleteClosed单事务 │ job_semantic_chunks ── INSERT 触发器 0182 ──► search_outbox ──► jobs 搜索索引的 desc embedder这条链路对应 cmd/embed/main.go 的装配config.LoadEmbed()读队列调参config.LoadEmbedClient()读嵌入后端参数search.NewClient(..., WithEmbedURL/WithEmbedAPIKey/WithEmbedConcurrency)组装客户端然后embed.Runner{Store: newDBStore(pool), Indexer: searchIndexer{client}}跑一次Run最后用worker.ExitCode(stats.Failed, stats.DeadLettered)决定退出码让 cron 可以告警。三、队列语义入队谓词与认领查询3.1EnqueuePendingSemanticJobs一个谓词覆盖三种过期internal/platform/db/queries/semantic.sql 中入队语句同时处理两类工作第一类需要重新嵌入的打开职位closed_at IS NULL AND duplicate_of IS NULL AND (semantic_embedded_model IS DISTINCT FROM sqlc.arg(target_model)::text OR semantic_embedded_hash IS DISTINCT FROM content_hash) AND is_tech IS TRUEIS DISTINCT FROM的组合一次覆盖三种场景场景触发条件新增semantic_embedded_model为 NULL内容更新semantic_embedded_hash与当前content_hash不一致模型迁移semantic_embedded_model与target_model不一致第二类不可索引但仍携带嵌入戳的职位closed_at IS NOT NULL OR duplicate_of IS NOT NULL且semantic_embedded_model IS NOT NULL入队目的是让 worker 去清理状态而不是再嵌入。两个关键细节值得注意is_tech IS TRUE闸门AGENTS.md 记载该闸门于 2026-07-22 取代了早期基于分类的NonTechCategories排除。原因很直接被删除的jobs_semantic索引中只有 35% 的文档带is_tech标签那是一片未分化的全量facet 索引与 enrichment 的闸门也正是针对同一个问题收紧的。源码注释同样强调这一点并且 cmd/embed/store.go 的Enqueue直接转发该查询。ON CONFLICT (job_id, target_model) DO NOTHING每个职位, 目标模型组合最多一条存活条目因此每次调用都跑一遍入队也绝不会重复派活。job_posted_at反规范化入队时把COALESCE(posted_at, created_at)落到 outbox 行上是为了让认领查询无需 joinjobs就能排序。3.2ClaimSemanticBatch故意不过滤不可索引职位这是enrich 认领 一处刻意分歧的查询WHERE o.failed_at IS NULL AND (o.claimed_at IS NULL OR o.claimed_at now() - make_interval(secs sqlc.arg(lease_seconds)::int)) ORDER BY o.job_posted_at DESC NULLS LAST, o.job_id DESC FOR UPDATE OF o SKIP LOCKED LIMIT sqlc.arg(batch_size)它不像ClaimEnrichmentBatch那样把不可索引职位排除掉而是通过RETURNING返回closed布尔值RETURNING o.id, o.job_id, (j.closed_at IS NOT NULL OR j.duplicate_of IS NOT NULL)::boolean AS closed;原因写在注释里closed / 非规范转载duplicate_of非空本身就是请清理状态的信号worker 必须收到它并按closed分支。查询形态上还有两处专门为规模做的优化排序键落在 outbox 自己身上若为了排序去 joinjobsPostgres 无法把LIMIT下推到排序之下必须嵌套循环并排序整个可认领集合。注释给出实测数据生产环境约 906k 可认领行时单次认领调用耗时109 秒。FOR UPDATE OF o只锁 outbox 行避免把jobs行一并锁住导致并发认领波次互相争用SKIP LOCKED让并发 worker 拿到互不相交的行租约谓词本身兼任崩溃回收器不需要单独的 reaper 进程。在 Go 侧cmd/embed/store.go 把db.ClaimSemanticBatchRow映射为embed.Claimed{OutboxID, JobID, Closed}。四、Runner批量优先逐条兜底internal/ai/embed的 runner 通过StoreIndexer两个端口与外界隔离单元测试用 fake 覆盖cmd/embed负责注入真实适配器dbStore、searchIndexer。这种布局的好处是批处理与降级逻辑可以完全用假实现验证见 internal/ai/embed/runner_test.go。4.1 端口定义运行时契约internal/ai/embed/runner.go 定义了三个类型与两个接口类型 / 接口关键语义Claimed{OutboxID, JobID, Closed}一条被租约的 outbox 条目Closed覆盖已关闭与非规范转载ChunkEmbedding{ChunkIndex int16, Vector []float32}一个分块的向量ChunkIndex对应job_semantic_chunks.chunk_indexStore.Enqueue/Claim/Jobs/CompleteOpen/CompleteClosed/Fail持久化侧端口交换db.Job行隐藏连接池与事务边界但不隐藏生成的行类型Indexer.IndexOpen嵌入侧只有一个方法——没有 remove closed 对应物ChunkEmbedding是在本包内重新定义的而不是复用internal/search的JobChunkEmbedding目的是让本包的端口不依赖internal/search的类型只有cmd/embed的具体适配器同时认识两者。Indexer没有Remove方法这一点正是 AGENTS.md 强调的关闭职位没有东西可计算或可嵌入的直接体现Store.CompleteClosed自己就是关闭路径的全部副作用。4.2 Run 主循环func (r Runner) Run(ctx context.Context, opt RunOptions) (Stats, error) { enqueued, err : r.Store.Enqueue(ctx, opt.TargetModel) ... _, err outbox.RunBatch(ctx, r.Store, outbox.RunOptions{ BatchSize: opt.BatchSize, LeaseSeconds: opt.LeaseSeconds, OnWave: func(outbox.Stats) { /* 心跳日志读 rn.stats */ }, }, rn.processWave, rn.unreachableFallback) ... }要点先入队再循环认领直到某次认领返回空为止——outbox.RunBatch是仓库内所有 outbox worker 共用的循环internal/platform/outbox/runner.go它只负责认领一波 → 处理 → 计数 → 重复从不碰持久化。OnWave心跳刻意读rn.stats而不是回调参数里的通用outbox.Stats因为后者四个通用水桶里没有Indexed/Removed的区分长时间排空时这行日志是唯一能看到跑分的地方。unreachableFallback直接 panicprocessWave永不返回 error所以RunBatch的外层逐条兜底永远不会被调用。用 panic 把这个不变量钉死而不是悄悄返回一个编造的结果。4.3processWave一次波次内先分组var open, closed []Claimed for _, e : range batch { if e.Closed { closed append(closed, e) } else { open append(open, e) } } before : rn.stats rn.processOpenBatch(ctx, open) rn.processClosedBatch(ctx, closed) return outbox.Stats{ Succeeded: (rn.stats.Indexed - before.Indexed) (rn.stats.Removed - before.Removed), ... }, nil注意波次内的并发并不来自RunBatchRunOptions中的Concurrency只对RunPool生效RunBatch忽略它。本管线的并发藏在Indexer内部TEI 调用按EMBED_CONCURRENCY分片并发所以波次串行处理、计数无需加锁——源码注释对此有明文。4.4 打开职位整批一次调用失败降级为逐条processOpenBatch的骨架是批处理 → 任一阶段失败则整波逐条重试callCtx, cancel : rn.callContext(ctx) // CallTimeout 0 时等价 WithCancel jobs, err : rn.store.Jobs(callCtx, jobIDs(entries)) if err ! nil { if rn.skipOnContextEnd(...) { return }; rn.fallbackOpen(ctx, entries); return } if len(jobs) ! len(entries) { rn.fallbackOpen(ctx, entries); return } chunks, err : rn.indexer.IndexOpen(callCtx, jobs) if err ! nil { if rn.skipOnContextEnd(...) { return }; rn.fallbackOpen(ctx, entries); return } if err : rn.store.CompleteOpen(callCtx, entries, rn.opt.TargetModel, chunks); err ! nil { if rn.skipOnContextEnd(...) { return }; rn.fallbackOpen(ctx, entries); return } rn.stats.Indexed len(entries)为什么整批一次包文档说得很清楚每一波作为一次 TEI 批量调用而非每职位一次加上一次 Postgres 事务持久化批量回填才不会被切成一堆小往返。降级为逐条的意义则是隔离毒行/坏行一条坏数据不能沉掉整波。逐条路径processOpenOne有两个立即死信的特例避免跨 cron 轮次白白消耗重试预算数据损坏行SQLSTATEXX001经pgerr.IsDataCorrupted判定永远加载不出来以maxAttempts1直接死信行不存在入队后职位被硬删除未来任何一轮都加载不到同样maxAttempts1。两者分别被TestRunnerCorruptedRowDeadLettersInFallback与TestRunnerMissingJobDeadLettersInFallback覆盖runner_test.go。4.5skipOnContextEnd超时/关停不等于失败这是本 worker 最容易被误解、也最值得复盘的机制。func (rn *run) skipOnContextEnd(callCtx context.Context, entries []Claimed, stage string) bool { cause : callCtx.Err() if cause nil { return false } log.Printf(embed: batch of %d abandoned during %s (%v, budget %s) — leaving claimed for lease-expiry retry, not falling back to per-item, len(entries), stage, cause, rn.opt.CallTimeout) return true }它按 context 而非 error 分类从而同时覆盖两种结束方式调用超时、整个 run 被取消且不依赖嵌入后端或驱动怎么包装错误。为什么不能在被取消时降级逐条超时降级仍然浪费真实工作量逐条 TEI 调用要把每次请求的固定开销付len(entries)次逐条CompleteOpen又把一次批量事务盖戳 替换 chunk 行 删 outbox换成len(entries)次。取消关停降级更糟逐条路径的Fail写在 background context 上一次重部署的 SIGTERM 就会给整波各记一次 attempt三次即死信而死信之后没有任何东西会把它救回来——ClaimSemanticBatch过滤failed_at IS NULLEnqueuePendingSemanticJobs是ON CONFLICT DO NOTHING这条队列没有 sweeper。结果是该职位的/similar列表静默为空。因此纯超时的波次会被留在租约里等租约过期由后续 run 整波重试。对应测试TestRunnerSkipsWaveOnBatchTimeoutInsteadOfCascading断言Indexed/Failed/DeadLettered全为 0且IndexOpen只被调用 1 次、Fail一次都没记runner_test.go。取消场景的TestRunnerSkipsWaveWhenTheRunIsCancelled与逐条路径的TestRunnerLeavesAnEntryClaimedWhenItsOwnCallIsCancelled则是同一条规则在整波和单条两个粒度上的验证。4.6 失败记账func (rn *run) fail(callCtx context.Context, entry Claimed, cause error) { if ctxErr : callCtx.Err(); ctxErr ! nil { log.Printf(embed: outbox%d abandoned (%v), leaving claimed for lease-expiry retry: %v, ...) return } rn.failN(entry, cause, rn.opt.MaxAttempts) } func (rn *run) failN(entry Claimed, cause error, maxAttempts int) { dead, err : rn.store.Fail(context.Background(), entry.OutboxID, cause.Error(), maxAttempts) ... }Fail的记账本身走context.Background()一个超时/取消的调用也必须能写下自己的失败原因而是否计一次 attempt先由callCtx.Err()判断。Fail背后是 RecordSemanticFailureattempts 1、记录last_error、达到上限则写failed_at并故意保留claimed_at——租约到期把重试推到后续 run同时充当崩溃回收器保证失败条目不会在同一次 run 内被反复处理。五、事务边界CompleteOpen与CompleteClosed5.1 打开路径一次事务里的四件事cmd/embed/store.go 的CompleteOpen在同一个事务中依次执行步骤SQL 查询作用1StampSemanticEmbeddedBatch写semantic_embedded_model model、semantic_embedded_hash content_hash2DeleteJobSemanticChunks一条WHERE job_id ANY($1)删除整波职位的旧分块3InsertJobSemanticChunks一条 INSERT 写入整波所有新分块三组平行数组4ClearSimilarComputedAtBatch整波把similar_computed_at置 NULL5DeleteSemanticEntriesBatch删除本波 outbox 行几个实现细节值得单独说明替换而非追加职位的分块数量可能在两次嵌入之间变化源文本被重新分块因此不存在稳定的chunk_index可 UPDATE 的目标必须 delete-then-insert。而且本波每一条都会被替换包括这次一个分块都没有的职位描述为空/极短否则职位会留下旧分块 新空集的混合状态。测试TestRunnerReplacesChunksWithEmptySetWhenIndexerReturnsNone专门钉住这一点Indexer返回 nil 时该职位的分块必须被清空而不是保留。delete 批量、insert 单次往返删除是对整波 job id 的一次调用插入把整波所有分块展平成三组按位置配对的平行数组一个职位的 3 个分块就把它的 id 重复 3 次一次unnest ... WITH ORDINALITY ... USING (n)完成。查询注释给出过代价早前每职位一次往返正是让一次 HF GPU 批量嵌入卡在 Postgres 而不是 GPU约 19 docs/s而 GPU 完成一波只需约 7 秒的原因。向量以 pgvector 字面量文本传输如[0.1,0.2,...]逐元素在 SELECT 里::vector(768)。原因本仓库没有为pgvector.Vector注册自定义 OID 编解码器driver.Valuer/sql.Scanner的兜底只覆盖单个标量列值不覆盖向量数组。ClearSimilarComputedAtBatch的无条件性分块刚被替换similar_job_ids必然过期甚至该职位是否具备预计算资格都可能变化因此整波无条件置 NULL让cmd/similar-backfill的增量谓词similar_computed_at IS NULL且至少有一行分块下一轮重新接手。这同时解释了SelectJobsNeedingSimilarBackfill为何只需一个IS NULL判断——缺失和过期已经坍缩为同一状态。dbStore.tx是标准的成功提交、否则回滚封装所有成功路径整批的盖戳 删 outbox都在一个事务里。5.2 关闭路径只做清理func (s *dbStore) CompleteClosed(ctx context.Context, entries []embed.Claimed) error { jobIDs, outboxIDs : splitIDs(entries) return s.tx(ctx, func(qtx *db.Queries) error { if err : qtx.ClearSemanticEmbeddedBatch(ctx, jobIDs); err ! nil { ... } if err : qtx.DeleteJobSemanticChunks(ctx, jobIDs); err ! nil { ... } return qtx.DeleteSemanticEntriesBatch(ctx, outboxIDs) }) }ClearSemanticEmbeddedBatch会把semantic_embedded_model、semantic_embedded_hash以及遗留的semantic_embedding一并置 NULL。对只在新分块流水线下嵌入过的职位最后这个赋值是空操作但对仍持有旧数据的职位它是一次真实清空——这也让删列可以安全地推迟到后续变更而不必先写一次性回填脚本。注意ON DELETE CASCADE只能覆盖硬删除cmd/prune那条路径软关闭到不了级联所以这里必须显式删分块行。六、嵌入本身分块、前缀与 TEI 调用6.1 分块流水线searchIndexer.IndexOpen只做一件事调client.EmbedJobChunks并转换成端口类型cmd/embed/indexer.go。真正的逻辑在 internal/search/search/embed.go 与 chunk.gostripToPlainText(job.Description)plaintext.goHTML → 纯文本块级元素边界保留为换行让分块切点落在段落之间chunkText按chunkBudgetRunes 2000的预算切分优先段落边界单个超预算段落回退为按词边界切分wrapWords连超长单词/URL 也按 rune 硬切避免无界分块加统一前缀每个分块都带passage: job.Title at job.Company . 因为每个分块都会成为独立打分的向量maxJobChunks 32768上限ChunkIndex以int16存储超过math.MaxInt16会绕成负数并与主键冲突。2000 rune 预算下这个上限意味着约 6500 万字符的描述现实中不可达它的存在只是防止远低于上限的描述悄悄产出损坏索引。没有可用描述文本的职位不会产生只有前缀的分块而是根本不出现在结果 map 里——这不是错误。EmbedJobChunks也因此对这类职位直接跳过。6.2 e5 的非对称性与向量宽度模型身份是intfloat/multilingual-e5-base-chunked-v1输出宽度 768对应job_semantic_chunks.embedding vector(768)client.go。-chunked-v1后缀的存在有明确目的即使底层 e5 模型未变passage 形状变了HTML 剥离、全长、分块身份字符串必须跟着变否则按旧形状截断、含 HTML盖过戳的职位永远不会被重新入队。e5 是非对称模型语料侧带passage:查询侧带query:。本仓库当前没有查询侧嵌入调用需要它的 CV 查询路径已随/me/recommendations一起移除API 服务器上为 agent 语义搜索服务的QueryEmbedder.EmbedQuery仍按query: text构造并校验维度必须等于 768。6.3 TEI 调用批量、并发与重试embedBatch把整波所有分块拼成一个输入列表按teiMaxBatch 32切成多个 TEI/embed调用分片TEI 的--max-client-batch-size默认 32并按EMBED_CONCURRENCY并发执行每个分片把向量写进自己的槽位因此即使完成顺序乱了最终结果仍与输入顺序一致。首个失败即cancel()掉其余分片。embedChunk负责单分片重试最多embedMaxAttempts 5次退避 1s、2s、4s…embedRetryBase (attempt-1)。可重试判定在embedCall中传输错误、429、5xx 可重试4xx、向量数量不匹配、无法识别的响应形状是确定性错误不重试父 context 已取消则立即终止。单次调用由embedAttemptTimeout 60s兜底http.DefaultClient自身没有超时。响应解析同时容忍 TEI 的裸数组[[...], ...]与 HF Inference Endpoints 的对象形式{embeddings: [[...]]}。超长输入交给后端截断host2 TEI 的--auto-truncate、HF 默认截断因此这里不做逐输入长度处理。6.4 分块如何抵达搜索索引AGENTS.md 中写入的分块集合通过触发器抵达搜索索引指的是 migrations/0182_job_semantic_chunks_enqueue_search.sql向job_semantic_chunks的 INSERT 会把对应的打开职位压入search_outbox从而在下一轮 drain 时把向量送进 jobs 索引的descembedder而不是等到下一次全量重建。选触发器而不是每个写入方各加一条语句是因为这张表有两个写入者cmd/embed以及志愿 brokerfreehire-volunteer-embed的promote另一个仓库里的裸 SQL。一个忘了入队的写入方看起来会和还没人嵌入过的职位一模一样不会有人报告。它是FOR EACH STATEMENT配合 transition table而不是FOR EACH ROW一次写入一个职位约贡献 2.5 行注释给出 2026-10-18 实测861,802 个打开职位上共 2,124,913 个分块且两个写入方都是整批一条语句插入。触发器只挂 INSERT——关闭路径只 DELETE关闭职位是通过删除 outbox 离开索引的不走这条队列。七、配置参数调什么、默认值、为什么7.1 队列调参config.LoadEmbed来自 internal/platform/config/embed.go全部可选、均有默认值没有任何必填项因此该函数永不失败环境变量默认值含义与影响EMBED_BATCH_SIZE500认领波次大小同时也是嵌入/持久化批量大小。越大批量回填的 TEI 调用集与 Postgres 往返越少因为并发在Indexer内部分片这个值可以很大数百级EMBED_LEASE_SECONDS300认领租约时长。会被抬到不低于EMBED_CALL_TIMEOUT_SECONDSEMBED_MAX_ATTEMPTS3死信前的失败次数上限EMBED_CALL_TIMEOUT_SECONDS600单批或逐条兜底的一次条目嵌入持久化的时间上限0表示不限后端自己有单次尝试超时两处硬性下限在源码里显式写了理由if e.BatchSize 1 { e.BatchSize 1 } // 非正 batch 会变成 LIMIT 0 或负 LIMIT if floor : int(e.CallTimeout.Seconds()); e.LeaseSeconds floor { e.LeaseSeconds floor }租约必须比单批处理更长否则在飞行中的批次会中途变得可再认领重复工作而租约为 0 会导致刚失败的条目在紧循环里被反复重认领一个 run 内就把重试预算烧光。600 秒这个上限沿用了历史上的SEARCH_DRAIN_CALL_TIMEOUT_SECONDS即便本 worker 已不再推搜索索引一个慢 TEI 后端或大批次 DB 事务仍需要足够余量才能让skipOnContextEnd把慢与失败区分开。7.2 嵌入后端config.LoadEmbedClient环境变量默认值含义EMBED_URL空 host2 TEI 的http://127.0.0.1:8090/embedTEI 兼容的/embed端点EMBED_API_KEY空authless认证端点的 bearer tokenEMBED_CONCURRENCY1每批在飞的嵌入调用数CPU 版 host2 TEI 提并发无收益远端 GPU 端点靠它隐藏单次延迟注意WithEmbedURL的语义空字符串保留默认而 API 服务器侧的语义搜索LoadSemanticSearch里EMBED_URL为空不意味着用默认 TEI而是意味着语义搜索不可用、相关请求只走关键词。7.3 一次性全量回填怎么做AGENTS.md 给出的操作路径是不需要新工具把search.CurrentEmbedderModel()的字符串改一次例如从intfloat/multilingual-e5-base到...-chunked-v1入队谓词的模型过期分支就会把整个打开目录重新入队随后由常规cmd/embed排空完成全量重嵌。回填期间把EMBED_URL指向更快的 GPU 端点以缩短排空时间。同时注意EMBED_PG_ONLY已被移除它原本跳过的 Meili 写入已经不存在每次运行本来就等效于纯 Postgres。docs/reviews/2026-08-01-architecture-review.md 里还留有一条历史记录该开关曾直接用os.Getenv读取而非走config.LoadEmbed现在已彻底不存在。八、调度与运行8.1 运行方式cmd/embed是跑一次、排空、退出的 worker适合放在 cron 上# 依赖DATABASE_URLworker.Bootstrap 读取可选 EMBED_* 调参 DATABASE_URLpostgres://... \ EMBED_URLhttp://127.0.0.1:8090/embed \ EMBED_BATCH_SIZE500 \ EMBED_CONCURRENCY8 \ go run ./cmd/embed启动流程由worker.Main/worker.Bootstrap统一负责config.Load()→ 初始化 Sentry → 建立信号 context → 连接数据库池internal/platform/worker/bootstrap.go。8.2 日志与退出码典型日志序列embed: enqueued 12345 pending, draining (batch500) embed: indexed batch of 500 in 3.2s embed: progress indexed500 removed12 failed0 dead0 embed: done: indexed12345 removed31 failed0 dead_lettered0四个计数器含义计数器来源含义indexedstats.Indexed成功嵌入并替换分块的打开职位数removedstats.Removed成功清理状态关闭 / 非规范转载的职位数failedstats.Failed本轮记了一次失败但未死信的条目数dead_letteredstats.DeadLettered本轮被死信的条目数退出码由worker.ExitCode(failed, deadLettered)决定任一非零即返回 1让 cron 能告警enqueue/claim层面的错误同样返回 1。九、一致性、幂等与已知取舍至少一次语义与 enrich 相同。TEI 调用与 PG 事务之间崩溃会导致重嵌但job_semantic_chunks的主键(job_id, chunk_index)加上先删后插的替换语义使重放天然幂等。盖戳使用的是盖戳时刻的content_hashStampSemanticEmbeddedBatch只接收 model job idssemantic_embedded_hash content_hash在 SQL 内取值。因此若嵌入读取内容后、盖戳前恰好有 ingest 提交了新的content_hash戳会记下新哈希而向量仍是旧内容入队谓词看到匹配、不会重新入队——该职位会带着落后一个修订的向量直到内容再次变化。这是一个被接受的取舍窗口只有一次嵌入时长且下一次真实变更会自愈。附带的可空安全性是content_hash为 NULL 时戳也写 NULLIS DISTINCT FROM不会导致无限重入队。死信不可自动恢复ClaimSemanticBatch过滤failed_at IS NULL、入队是ON CONFLICT DO NOTHING、队列没有 sweeper。这也正是第 4.5 节不在取消时记账如此重要的原因——一旦死信只能人工处理。共享 TEI 会拖慢 API 查询嵌入cmd/embed与 API 服务器的查询嵌入共用同一个 TEI。AGENTS.md 给出 2026-10-18 的实测空闲 TEI 上一次查询嵌入 0.09s批量排空期间 8–10s 或超时。搜索结果不会崩——会退回关键词搜索并报告semantic: unavailable——但一次长时间追赶freehire-embed-supervisor回填就是一个 agent 只能拿到关键词答案的窗口。结论是如果这个窗口开始变得重要正解是给查询单独部署一个 TEI。批量回填的瓶颈可能不在 GPU除了分块插入的批量化注释DeleteJobSemanticChunks的注释也强调每行一次 Postgres 往返曾让整条管线卡在 DB当EMBED_BATCH_SIZE默认为 500 时逐职位循环删除会在 160–200 万职位级别的全量重嵌上重新引入同样的回归。十、可观测性与相关 worker除了运行日志队列状态还有两个廉价查询可供采样semantic.sqlSemanticOutboxClaimableDepthfailed_at IS NULL的行数走semantic_outbox_claimable_idx部分索引注释给出生产环境 4.5ms够cmd/server每 15 秒采样一次SemanticCoverageCountseligible打开、非转载、is_tech、非私有与covered模型与哈希都匹配两个计数供公开的嵌入进度页使用。它是全表扫描注释记录生产环境在负载主机上 60 秒内跑不完只跑在cmd/rollup-stats绝不上请求路径。如果继续深入建议按以下顺序阅读关注点入口运行时契约与批处理/降级逻辑internal/ai/embed/runner.go批处理与超时/取消的行为验证internal/ai/embed/runner_test.go事务与批量 SQL 落地cmd/embed/store.go队列与分块 SQL含全部设计注释internal/platform/db/queries/semantic.sql分块与 TEI 调用internal/search/search/embed.go、chunk.go、plaintext.go调参读取与下限逻辑internal/platform/config/embed.go分块表与 HNSW 索引migrations/0092_jobs_semantic_embedding_vec.sql、migrations/0110_job_semantic_chunks_hnsw_idx.sql分块写入触发搜索队列migrations/0182_job_semantic_chunks_enqueue_search.sql下游消费者相似职位预计算cmd/similar-backfill/main.go设计取舍原文openspec/changes/drop-hybrid-search-pgvector-similar/design.md赞分享【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址https://gitcode.com/GitHub_Trending/fr/freehire点击查看免费下载相关推荐用 CocoIndex 构建 PDF 语义搜索docling 解析、向量化嵌入与 pgvector 增量索引实战用 CocoIndex 构建 PDF 语义搜索docling 解析、向量化嵌入与 pgvector 增量索引实战 导读 本文以 CocoIndex 仓库中的人工智能RAG数据工程数据集成如何用 NAM 快速复刻真实吉他音箱的音色神经网络建模入门指南如何用 NAM 快速复刻真实吉他音箱的音色神经网络建模入门指南 想把那台只在家里棚里的音箱音色带到巡演上或者给一首老歌补录一轨当年那台设备的声音传统做深度学习AI 应用音频处理Pot跨平台划词翻译与截图 OCR 完整指南Pot跨平台划词翻译与截图 OCR 完整指南 Pot 是一款开源的跨平台划词翻译与 OCR光学字符识别软件基于 TauriRust React 跨桌面应用OCRAI 应用上一篇Vibe 3.1.2 发布详解设置持久化回归修复、录音响度 9 dB、模型加载内存减半与 Agent 技能安装下一篇CKEditor 5 Undo/Redo 撤销重做功能深度解析基于 Batch 批处理的文档历史机制创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考