跳到主要内容

知识从哪来 — RAG 知识管线

第 2 章里 prompt 反复强调"只能用工具拿到的信息回答"。本章讲那个"信息"是怎么入库、又怎么被检索出来的——也就是 Captain 的 RAG(检索增强生成) 地基。

3.1 要解决的小问题:模型不知道你公司的事

LLM 训练数据里没有"你公司的退款政策"。直接问它,它要么不知道、要么瞎编。RAG 的套路是:先把你的资料检索出来,塞进 prompt,再让模型基于这段资料作答

Captain 的 RAG 分两段:

  1. 入库(离线):把帮助文档拆成一问一答,每条算出一个向量存起来。
  2. 检索(在线):客户提问时,把问题也变成向量,找库里最像的几条,喂给 agent。

3.2 三个核心数据模型

模型是什么关键字段
Captain::Document一篇原始资料:网页 URL 或 PDFexternal_linkcontentsync_status
Captain::AssistantResponse从文档拆出的一条 FAQ 问答对 + 向量questionanswerembedding(vector 1536)
Captain::Assistant拥有上面两者,检索的入口responses.approved.search(query)

3.3 入库管线:从一个 URL 到一堆带向量的 FAQ

怎么读这张图: 从上到下是数据流;每个框是一个 model 回调或后台 Job。

你添加一个 Document(URL 或 PDF)
│ after_create_commit

Captain::Documents::CrawlJob ← 抓取网页/解析 PDF,填充 document.content
│ content 就绪 → after_commit

Captain::Documents::ResponseBuilderJob
│ 调 LLM 把 content 拆成 FAQ 数组

Captain::Llm::FaqGeneratorService ← "读这篇文章,产出 {faqs:[{question,answer}...]}"
│ 每条 faq

AssistantResponse.create!(question, answer)
│ after_commit(问/答变了)

Captain::Llm::UpdateEmbeddingJob ← 调 OpenAI embedding,把 "question: answer" 变成 1536 维向量


embedding 列写回(pgvector)→ 可被检索

第一步,文档触发抓取。 Document 存下后自动派抓取任务:

# enterprise/app/models/captain/document.rb:59
after_create_commit :enqueue_crawl_job
# ...
after_commit :enqueue_response_builder_job # content 就绪后,派拆 FAQ 任务

第二步,LLM 把整篇文章拆成问答对。 这一步用 response_format: json_object 强制 JSON 输出:

# enterprise/app/services/captain/llm/faq_generator_service.rb:12
def generate
response = instrument_llm_call(instrumentation_params) do
chat.with_params(response_format: { type: 'json_object' })
.with_instructions(system_prompt) # "把这篇文档拆成 FAQ"
.ask(@content)
end
parse_response(response.content) # 取出 {faqs:[...]}
end

第三步,落成 AssistantResponse。 ResponseBuilderJob 先清掉旧的(没被人工编辑过的)响应,再逐条创建:

# enterprise/app/jobs/captain/documents/response_builder_job.rb:64
def reset_previous_responses(response_document)
response_document.responses.where(edited: false).destroy_all # 只删没编辑过的,保住人工修订
end

注意 edited: false 这个条件——人类坐席手动改过的 FAQ(edited 标志见 assistant_response.rb:38mark_as_edited)在重新同步时不会被覆盖

3.4 向量化:embedding 怎么算

问答对存下后,只要 question 或 answer 变了(或还没向量),就异步算向量:

# enterprise/app/models/captain/assistant_response.rb:68
def update_response_embedding
return unless saved_change_to_question? || saved_change_to_answer? || embedding.nil?
Captain::Llm::UpdateEmbeddingJob.perform_later(self, "#{question}: #{answer}") # 拼"问: 答"一起向量化
end

向量列在数据库里就是 pgvector 的 vector(1536) 类型(对应 OpenAI text-embedding 的维度),并建了 ivfflat 索引:

# enterprise/app/models/captain/assistant_response.rb:9 Schema 注释
# embedding :vector(1536)
# vector_idx_knowledge_entries_embedding (embedding) USING ivfflat

3.5 在线检索:余弦最近邻取 top-5

检索用 neighbor gem 的 has_neighbors,做归一化余弦距离的最近邻:

# enterprise/app/models/captain/assistant_response.rb:32
has_neighbors :embedding, normalize: true

# :49
def self.search(query, account_id: nil)
embedding = Captain::Llm::EmbeddingService.new(account_id: account_id).get_embedding(query)
nearest_neighbors(:embedding, embedding, distance: 'cosine').limit(5) # top-5 最相似
end

直觉: 把每条 FAQ 和客户问题都映射成 1536 维空间里的一个点,"语义越接近、点越近"。检索就是"找离客户问题最近的 5 个点"。这比关键词匹配强在——客户问"我想取消订阅",能命中"如何退订?"这条 FAQ,即使用词不同。

3.6 检索怎么接回 agent

agent 不直接调 search,而是通过 FAQ 工具。这是第 2 章"只能用工具拿信息"那条约束的落点:

# enterprise/lib/captain/tools/faq_lookup_tool.rb:5
def perform(_tool_context, query:)
responses = @assistant.responses.approved.search(query).to_a # ← 调上面的向量检索
responses.empty? ? "No relevant FAQs found for: #{query}" : format_responses(responses)
end

注意 .approved——只检索已批准的 FAQ(status 枚举见 assistant_response.rb:47)。LLM 拆出来的 FAQ 默认就是 approved,但这给了"先人工审核再放出去"的余地。

格式化时,如果这条 FAQ 有来源链接,还会把 Source: 附上,让 agent 能给客户引用出处(faq_lookup_tool.rb:31)。

3.7 一个完整闭环(教学示意)

把上面串起来,一次"客户问退款"的检索是这样(伪代码,演示数据流):

# 示意,非源码 —— 把 3.5/3.6 的真实链路用 Python 演一遍
def faq_lookup(query): # agent 调的工具
q_vec = openai_embed(query) # 客户问题 → 向量
rows = db.query(""" # pgvector 余弦最近邻
SELECT question, answer
FROM captain_assistant_responses
WHERE status = 'approved'
ORDER BY embedding <=> %s # <=> 是余弦距离算子
LIMIT 5
""", q_vec)
return "\n".join(f"Q: {r.question}\nA: {r.answer}" for r in rows)
# 重点看:agent 把这段返回值塞回上下文,再据此组织给客户的最终答复

小结: RAG 让 Captain"有据可依"。文档离线拆成带向量的 FAQ,在线用余弦检索 top-5 喂给 agent。下一章看 agent 手里除了 FAQ 还有哪些工具,以及面向人类坐席的 Copilot。