【免费下载链接】freehire
freehire — the open-source search engine for job seekers
本文以 internal/ai/embed/AGENTS.md 为骨架,系统拆解 freehire 的增量语义嵌入管线:cmd/embed如何借助semantic_outbox队列找到"缺向量/向量过期/需要清理"的职位,如何用 TEI 把职位描述分块嵌入并写入 pgvector 的job_semantic_chunks,以及这条链路在事务边界、失败重试与并发一致性上的取舍。读完后你应能独立配置、调度、排障这条管线,并理解它为何"唯一剩余效果只是一次 Postgres 写入"。
一、管线的范围与定位:它已经不写搜索索引了
1.1 当前职责只剩一件事
internal/ai/embed的定位是通过semantic_outbox队列做增量语义嵌入,与 enrichment(cmd/enrich)管线同构。它当前唯一的效果是一个 Postgres 写入目标:
job_semantic_chunks:pgvector 支撑的分块向量表,cmd/similar-backfill实际读取的就是这张表;- 遗留的
jobs.semantic_embedding(real[])列不再被本管线写入,只在关闭职位的清理路径上被"清空"(永不填充); - 过去由本包构建并查询的
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_KEY,search.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 自己身上:若为了排序去 join
jobs,Postgres 无法把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 通过Store+Indexer两个端口与外界隔离,单元测试用 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_index |
Store.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直接 panic:processWave永不返回 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注意"波次内的并发并不来自RunBatch":RunOptions中的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 轮次白白消耗重试预算:
- 数据损坏行(SQLSTATE
XX001,经pgerr.IsDataCorrupted判定):永远加载不出来,以maxAttempts=1直接死信; - 行不存在:入队后职位被硬删除,未来任何一轮都加载不到,同样
maxAttempts=1。
两者分别被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 NULL,EnqueuePendingSemanticJobs是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背后是 RecordSemanticFailure:attempts + 1、记录last_error、达到上限则写failed_at,并故意保留claimed_at——租约到期把重试推到后续 run,同时充当崩溃回收器,保证失败条目不会在同一次 run 内被反复处理。
五、事务边界:CompleteOpen与CompleteClosed
5.1 打开路径:一次事务里的四件事
cmd/embed/store.go 的CompleteOpen在同一个事务中依次执行:
| 步骤 | SQL 查询 | 作用 |
|---|---|---|
| 1 | StampSemanticEmbeddedBatch | 写semantic_embedded_model = model、semantic_embedded_hash = content_hash |
| 2 | DeleteJobSemanticChunks | 一条WHERE job_id = ANY($1)删除整波职位的旧分块 |
| 3 | InsertJobSemanticChunks | 一条 INSERT 写入整波所有新分块(三组平行数组) |
| 4 | ClearSimilarComputedAtBatch | 整波把similar_computed_at置 NULL |
| 5 | DeleteSemanticEntriesBatch | 删除本波 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.go:
stripToPlainText(job.Description)(plaintext.go):HTML → 纯文本,块级元素边界保留为换行,让分块切点落在段落之间;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,以及志愿 broker(freehire-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_SIZE | 500 | 认领波次大小,同时也是嵌入/持久化批量大小。越大,批量回填的 TEI 调用集与 Postgres 往返越少;因为并发在Indexer内部分片,这个值可以很大(数百级) |
EMBED_LEASE_SECONDS | 300 | 认领租约时长。会被抬到不低于EMBED_CALL_TIMEOUT_SECONDS |
EMBED_MAX_ATTEMPTS | 3 | 死信前的失败次数上限 |
EMBED_CALL_TIMEOUT_SECONDS | 600 | 单批(或逐条兜底的一次条目)嵌入+持久化的时间上限;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/embed | TEI 兼容的/embed端点 |
EMBED_API_KEY | 空(authless) | 认证端点的 bearer token |
EMBED_CONCURRENCY | 1 | 每批在飞的嵌入调用数;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_URL(worker.Bootstrap 读取),可选 EMBED_* 调参 DATABASE_URL='postgres://...' \ EMBED_URL='http://127.0.0.1:8090/embed' \ EMBED_BATCH_SIZE=500 \ EMBED_CONCURRENCY=8 \ go run ./cmd/embed启动流程由worker.Main/worker.Bootstrap统一负责:config.Load()→ 初始化 Sentry → 建立信号 context → 连接数据库池(internal/platform/worker/bootstrap.go)。
8.2 日志与退出码
典型日志序列:
embed: enqueued 12345 pending, draining (batch=500) embed: indexed batch of 500 in 3.2s embed: progress indexed=500 removed=12 failed=0 dead=0 embed: done: indexed=12345 removed=31 failed=0 dead_lettered=0四个计数器含义:
| 计数器 | 来源 | 含义 |
|---|---|---|
indexed | stats.Indexed | 成功嵌入并替换分块的打开职位数 |
removed | stats.Removed | 成功清理状态(关闭 / 非规范转载)的职位数 |
failed | stats.Failed | 本轮记了一次失败但未死信的条目数 |
dead_lettered | stats.DeadLettered | 本轮被死信的条目数 |
退出码由worker.ExitCode(failed, deadLettered)决定:任一非零即返回 1,让 cron 能告警;enqueue/claim层面的错误同样返回 1。
九、一致性、幂等与已知取舍
- 至少一次语义:与 enrich 相同。TEI 调用与 PG 事务之间崩溃会导致重嵌;但
job_semantic_chunks的主键(job_id, chunk_index)加上"先删后插"的替换语义,使重放天然幂等。 - 盖戳使用的是盖戳时刻的
content_hash:StampSemanticEmbeddedBatch只接收 model + job ids,semantic_embedded_hash = content_hash在 SQL 内取值。因此若嵌入读取内容后、盖戳前恰好有 ingest 提交了新的content_hash,戳会记下新哈希而向量仍是旧内容,入队谓词看到匹配、不会重新入队——该职位会带着"落后一个修订"的向量,直到内容再次变化。这是一个被接受的取舍:窗口只有一次嵌入时长,且下一次真实变更会自愈。附带的可空安全性是:content_hash为 NULL 时戳也写 NULL,IS 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.sql):
SemanticOutboxClaimableDepth:failed_at IS NULL的行数,走semantic_outbox_claimable_idx部分索引,注释给出生产环境 4.5ms,够cmd/server每 15 秒采样一次;SemanticCoverageCounts:eligible(打开、非转载、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 |
【免费下载链接】freehire
freehire — the open-source search engine for job seekers
相关推荐
用 CocoIndex 构建 PDF 语义搜索:docling 解析、向量化嵌入与 pgvector 增量索引实战
用 CocoIndex 构建 PDF 语义搜索:docling 解析、向量化嵌入与 pgvector 增量索引实战 导读 本文以 CocoIndex 仓库中的
人工智能RAG数据工程数据集成如何用 NAM 快速复刻真实吉他音箱的音色:神经网络建模入门指南
如何用 NAM 快速复刻真实吉他音箱的音色:神经网络建模入门指南 想把那台只在家里棚里的音箱音色带到巡演上,或者给一首老歌补录一轨"当年那台设备"的声音?传统做
深度学习AI 应用音频处理Pot:跨平台划词翻译与截图 OCR 完整指南
Pot:跨平台划词翻译与截图 OCR 完整指南 Pot 是一款开源的跨平台划词翻译与 OCR(光学字符识别)软件,基于 Tauri(Rust + React 跨
桌面应用OCRAI 应用
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考