Elixir嵌入式RAG引擎Arcana:原生集成与智能检索实践
1. 项目概述:一个为Elixir生态量身打造的嵌入式RAG引擎
如果你正在用Elixir和Phoenix构建应用,并且想为它加上一个智能的“知识大脑”,让应用能理解、检索并回答用户基于私有数据的问题,那么你很可能已经研究过RAG(检索增强生成)方案。传统的路径往往令人头疼:你需要引入一个独立的向量数据库(比如Pinecone、Weaviate),用Python写一套索引和检索服务,再通过HTTP API与你的Elixir应用通信。这不仅增加了运维复杂度,还让整个系统的可观测性变得支离破碎——日志、指标、链路追踪分散在多个技术栈里。
Arcana的出现,正是为了解决这个痛点。它的核心主张是:RAG应该成为你Elixir应用的一个原生功能,而不是一个外部服务。它直接嵌入到你的Phoenix应用中,复用你已有的PostgreSQL数据库(通过pgvector扩展)和Ecto仓库,将文档、向量、知识图谱全部存储在同一个数据库里。这意味着,你不需要为了RAG而引入任何新的基础设施组件。更关键的是,它充分利用了BEAM虚拟机的特性:嵌入模型和重排序器以Nx.Serving的形式运行在你的监督树中,所有操作产生的遥测事件都通过Elixir内置的:telemetry库发出,与你应用的其他部分无缝集成。
简单来说,Arcana让你能用几行Elixir代码,就在应用中实现从“简单问答”到“多步推理代理”的完整RAG能力。它不是一个“胶水”库,而是一个为Elixir并发模型和可观测性理念深度设计的、原生的RAG框架。
2. 核心设计哲学:为什么“嵌入式”是Elixir RAG的最优解
在深入代码之前,理解Arcana的设计哲学至关重要。这决定了它为什么选择现在的架构,以及它能在哪些场景下发挥最大优势。
2.1 告别“微服务缝合怪”
许多RAG方案本质上是“微服务缝合怪”。向量数据库、嵌入服务、重排序服务、LLM网关各自为政,通过HTTP或gRPC连接。这种架构带来了几个Elixir开发者深恶痛绝的问题:
- 延迟与故障点:每一次检索都可能涉及多次网络调用,延迟叠加,且任何一个服务宕机都会导致整个RAG链路失败。
- 运维负担:你需要部署、监控、扩容至少3-4个独立服务。
- 可观测性割裂:追踪一个用户查询的完整生命周期变得异常困难,日志和指标散落在各处。
Arcana认为,对于大多数中小型应用和团队,这种复杂度是不必要的。BEAM虚拟机本身就是为构建高并发、容错、可观测的系统而生的。将RAG能力内化,正是发挥BEAM优势的绝佳场景。
2.2 拥抱BEAM和Phoenix的现有能力
Arcana的设计紧密贴合Elixir生态的现有强项:
- 一个仓库(Repo)管所有:你的应用已经有一个
Ecto.Repo用于业务数据。Arcana只是在这个数据库里创建了几张新表(documents,chunks,embeddings,knowledge_graph_nodes等)。所有CRUD和向量搜索都通过这个Repo进行,事务一致性、连接池管理、数据迁移都沿用现有模式。 - 监督树(Supervision Tree)是天然的服务容器:长期运行的嵌入模型、重排序模型,被包装成
Nx.Serving进程,挂载在你的应用监督树下。它们享受BEAM的容错机制(崩溃重启)和热代码升级能力。 - 遥测(Telemetry)是统一的可观测层:从文档分块、向量化、检索到LLM调用,Arcana的每一个步骤都发射标准的
:telemetry事件。这意味着你可以用同样的工具(如Telemetry.Metrics+Phoenix.LiveDashboard)来监控你的RAG流水线,就像监控Web请求和数据库查询一样。 - LiveView是现成的管理界面:Arcana自带一个功能完整的LiveView仪表盘,你可以直接将它挂载到你的路由中。这个仪表盘能展示检索统计、知识图谱可视化、查询历史等,无需额外开发。
2.3 可插拔,但不抽象
这是Arcana在易用性和灵活性之间找到的平衡点。它提供了开箱即用的默认实现,覆盖了80%的常见用例。例如,默认使用本地的Bumblebee模型进行嵌入和重排序,默认使用pgvector进行混合搜索。
但同时,它的每一个核心组件(分块器Chunker、嵌入器Embedder、搜索器Searcher、重排序器Reranker、回答器Answerer)都定义了一个清晰的Behaviour(行为)。如果你需要定制,比如接入一个专有的嵌入API,或者实现一个基于业务逻辑的复杂重排序算法,你只需要实现一个包含单个回调函数的模块,然后在配置中替换掉默认实现即可。这种设计避免了过度抽象带来的复杂性,让定制化变得直观且低成本。
注意:虽然Arcana鼓励“本地优先”,但它并非封闭。你可以轻松地将默认的本地Bumblebee嵌入器切换为OpenAI、Cohere、Z.ai等云服务提供商。库的设计确保了这种切换是配置层面的改变,而非架构重构。
3. 三种使用模式:从高级检索到智能代理
Arcana没有提供“一刀切”的API,而是根据任务的复杂度和对控制流的需求,提供了三种渐进的抽象层。这直接对应了学术界对Agentic RAG的分类。
3.1 高级RAG模式:Arcana.search/2与Arcana.ask/2
这是最常用、最直接的入口。你不需要关心内部步骤,一次调用就能获得一个经过优化的、包含上下文的结果。
Arcana.search/2:执行检索。它内部会自动进行查询改写(将口语化问题转化为更适合检索的查询)、混合搜索(结合向量语义搜索和全文关键词搜索)、结果融合(使用RRF算法),如果配置了知识图谱,还会进行图谱融合。最后,它默认会用一个本地的交叉编码器模型对结果进行重排序,提升Top-K的准确性。# 一个调用,完成从查询到精排结果的全过程 {:ok, results} = Arcana.search(“Phoenix LiveView如何处理客户端事件?”, repo: MyApp.Repo) # `results` 是一个包含精排后文档片段的列表,每个片段都有相关性分数。Arcana.ask/2:在search的基础上,增加生成步骤。它将检索到的最佳片段作为上下文,发送给LLM,生成一个自然语言的答案。{:ok, answer} = Arcana.ask(“Phoenix LiveView如何处理客户端事件?”, repo: MyApp.Repo, llm: “openai:gpt-4o-mini”) # `answer` 是一个结构体,包含生成的文本、引用的来源片段、以及可选的置信度分数。
何时使用:当你有一个明确的问题,且期望一个直接的检索或问答结果时。这是大多数场景下的首选。
3.2 模块化RAG模式:Arcana.Pipeline
当你需要对检索-生成流程的每一个环节进行精细控制时,就需要Pipeline。它允许你将RAG流程分解为独立的、可组合的步骤,并可以轻松地插入自定义逻辑。
一个典型的Pipeline流程如下:
alias Arcana.Pipeline # 1. 创建上下文 ctx = Pipeline.new(“比较Elixir和Erlang在构建Web服务上的优劣”, repo: MyApp.Repo, llm: “anthropic:claude-3-5-sonnet”) # 2. 执行流程链 ctx = ctx |> Pipeline.gate() # 步骤A:判断此问题是否需要检索(避免无谓搜索) |> Pipeline.rewrite() # 步骤B:查询改写 |> Pipeline.expand() # 步骤C:查询扩展(生成同义词或相关问题) |> Pipeline.decompose() # 步骤D:问题分解(将复杂问题拆成子问题) |> Pipeline.search() # 步骤E:对每个子问题进行检索 |> Pipeline.rerank() # 步骤F:对检索结果进行重排序 |> Pipeline.reason() # 步骤G:让LLM基于所有片段进行推理 |> Pipeline.answer() # 步骤H:生成最终答案 |> Pipeline.ground() # 步骤I:对答案进行事实性核查(防幻觉) # 3. 获取结果 final_answer = ctx.answer grounding_score = ctx.grounding.score # 事实性得分,越高越好 search_results = ctx.searches # 中间所有的检索结果核心优势:
- 完全可控:你可以决定启用或跳过某些步骤(比如,如果确信所有问题都需要检索,可以跳过
gate)。 - 易于调试:每个步骤的输入和输出都保存在上下文
ctx中,你可以插入IO.inspect或发送遥测事件来观察流水线的状态。 - 易于定制:每个步骤(
gate,rewrite,search等)都是一个Behaviour。你可以实现自己的模块来覆盖默认行为。例如,实现一个基于业务规则的custom_gate,当问题涉及敏感话题时直接返回预设回答。
何时使用:当你需要实现复杂的检索逻辑(如多跳问答、迭代检索)、或者需要深度集成业务规则时。
3.3 智能代理模式:Arcana.Loop
这是最灵活、也是最“智能”的模式。它将控制流完全交给LLM(称为Controller)。LLM在每一轮迭代中,可以自主决定调用哪个工具(search,answer,give_up),直到它认为自己收集了足够的信息来回答问题,或者决定放弃。
# 1. 创建一个代理循环,针对“doctor-who”知识库 {:ok, ctx} = Arcana.Loop.new( “找出所有Time Lord背叛Doctor的剧集”, repo: MyApp.Repo, collection: “doctor-who” # 锁定到特定知识集合 ) |> Arcana.Loop.run( controller_llm: “openai:gpt-4o-mini”, # 用于决策的“大脑”,可以便宜快速 answer_llm: “openai:gpt-4o”, # 用于生成最终答案的模型,可以更强 max_iterations: 5 # 防止无限循环 ) # 2. 查看结果 case ctx.terminated_by do :answered -> IO.puts “答案: #{ctx.answer}” IO.inspect ctx.tool_history # 查看LLM都做了哪些决策,例如: [:search, :search, :answer] :gave_up -> IO.puts “代理放弃了,未能找到答案。” :max_iterations -> IO.puts “达到最大迭代次数,以下是目前综合的信息: #{ctx.synthesis}” :error -> IO.puts “执行过程中出错。” end # 3. (可选)进行事实性核查 ctx = Arcana.Loop.ground(ctx) IO.puts “答案可信度: #{ctx.grounding.score}”工作流程解析:
Loop.new初始化一个循环上下文,包含初始问题。Loop.run启动循环。controller_llm作为“驾驶员”,根据当前上下文和可用工具描述,决定下一步行动。- 如果它调用
search,Arcana会执行检索,并将结果摘要(而非原始片段)加入上下文。 - 循环继续,LLM可以基于新的上下文进行更深入的搜索或推理。
- 当LLM调用
answer工具时,循环终止,answer_llm被调用来基于所有累积的上下文生成最终答案。 - 如果LLM调用
give_up或达到max_iterations,循环也会终止,并可能触发一个后备的synthesis(综合)步骤来生成一个临时答案。
何时使用:适用于开放域、多跳、推理路径不明确的复杂问题。例如,“根据我们公司的销售报告和客户反馈,分析产品A在Q3表现不佳的根本原因,并给出建议”。这类问题无法通过一次检索解决,需要LLM自主规划检索策略。
4. 核心组件深度解析与实操配置
理解了宏观模式,我们深入到构成Arcana的各个核心部件。它们的配置决定了系统的性能、准确性和成本。
4.1 嵌入器(Embedder):本地与云服务的抉择
嵌入模型是将文本转化为向量的核心。Arcana默认集成Bumblebee,让你可以在自己的服务器上运行开源模型。
本地嵌入(Bumblebee + Nx):
- 优势:零网络延迟,数据不出私域,无API调用成本。
- 配置示例(在
application.ex或专属监督树中):# 在应用的监督树中启动一个嵌入服务 children = [ # ... 其他子进程 {Nx.Serving, serving: Arcana.Embedder.Bumblebee.default_serving(“BAAI/bge-small-en-v1.5”), name: Arcana.Embedder.Bumblebee } ] - 模型选择建议:
BAAI/bge-small-en-v1.5: 轻量级,英文效果好,是平衡速度和质量的默认选择。intfloat/e5-small-v2: 支持指令前缀(如query:,passage:),在某些检索任务上表现更好。Arcana支持自动添加前缀。sentence-transformers/all-MiniLM-L6-v2: 经典模型,兼容性好。
- 性能调优:
- 批处理:
Nx.Serving默认支持批处理。确保在ingest(灌库)时一次性传入多个文档或片段,以最大化GPU/CPU利用率。 - 后端:在
config/config.exs中配置Nx后端。对于GPU,使用EXLA;对于Apple Silicon,使用EMLX;对于CPU,使用Torchx(在某些CPU上可能更快)。import Config config :nx, default_backend: EXLA.Backend # 使用GPU
- 批处理:
云端嵌入(OpenAI/Cohere等):
- 优势:免维护,通常使用最先进的商用模型(如
text-embedding-3-small),效果可能更好。 - 配置示例:
# config/config.exs config :arcana, :embedder, provider: Arcana.Embedder.OpenAI, api_key: System.get_env(“OPENAI_API_KEY”), model: “text-embedding-3-small” - 成本与延迟考量:对于高频检索场景,API调用成本和延迟可能成为瓶颈。可以考虑混合策略:灌库时使用本地模型(数据量大,对延迟不敏感),在线检索时使用云端模型(追求最高精度)。
4.2 分块器(Chunker):如何切分你的文档
文档分块是RAG的“第一步”,其策略直接影响检索质量。糟糕的分块会导致信息碎片化或上下文丢失。
Arcana提供了默认的基于标记(Token)和重叠(Overlap)的分块策略,通常效果不错。但你应当根据你的文档类型进行调整。
# 自定义分块配置 config :arcana, :chunker, module: Arcana.Chunker.Token, # 默认实现 size: 512, # 每个块的最大token数 overlap: 50, # 块与块之间重叠的token数 separators: [“\n\n”, “\n”, “。”, “.”, “?”, “!”, “,”] # 优先在这些分隔符处切分分块策略经验谈:
- 技术文档/代码:适合较小的块(256-512 tokens),并利用Markdown标题(
#,##)作为分隔符,确保每个块主题明确。可以考虑实现一个自定义的MarkdownChunker。 - 长篇文章/小说:适合较大的块(1024 tokens),并设置一定的重叠(如10%),以确保段落边界的连续性。
- 表格数据:这是分块的难点。简单的按行切分会破坏结构。一种策略是将整个表格作为一个块,并为其生成一个详细的文本描述(如“下表展示了2023年各季度的销售额...”)作为该块的元数据,辅助检索。
- 实操心得:不要盲目追求小块。过小的块会丢失必要的上下文,导致检索到的片段无法让LLM理解。一个实用的方法是,用一批典型问题对你的知识库进行检索测试,人工评估返回的片段是否“自包含”地包含了回答问题所需的信息。
4.3 搜索器(Searcher)与混合搜索策略
Arcana的搜索器默认执行混合搜索(Hybrid Search),这是提升召回率的关键。
- 向量搜索(语义):使用
pgvector的余弦相似度或内积运算,找到与查询向量最接近的文档块。它理解语义,能处理“同义不同词”的情况。 - 全文搜索(关键词):使用PostgreSQL的
tsvector/tsquery进行全文检索。它精确匹配关键词,能处理专有名词、缩写和拼写纠错。 - 结果融合:Arcana使用**倒数排序融合(Reciprocal Rank Fusion, RRF)**算法将两个结果列表合并。RRF的优势在于它不依赖分数绝对值(不同搜索算法的分数尺度不同),只依赖排名,能公平地结合两种搜索方式的优势。
# 在调用搜索时,可以调整混合搜索的权重 {:ok, results} = Arcana.search(“什么是OTP?”, repo: MyApp.Repo, hybrid_weights: %{vector: 0.7, full_text: 0.3} # 更偏向语义搜索 )权重调整建议:
- 知识库高度专业化,术语固定:提高全文搜索权重(如
%{vector: 0.4, full_text: 0.6})。例如,搜索内部API接口名。 - 知识库内容多为概念解释、描述性文字:提高向量搜索权重(如
%{vector: 0.8, full_text: 0.2})。例如,搜索“如何实现容错机制”。 - 最佳实践:建立一个包含几十个典型问题的小型测试集,通过Arcana的评估工具(见后文)计算不同权重下的MRR(平均倒数排名)或Hit@K指标,从而科学地确定最优权重。
4.4 重排序器(Reranker):精排的最后一公里
即使混合搜索找到了相关文档,它们也可能不是最相关的。重排序器作为一个“精排”阶段,对Top-N(例如,50个)的候选结果进行更精细的评分。
Arcana默认使用一个**本地交叉编码器(Cross-Encoder)**模型,例如cross-encoder/ms-marco-MiniLM-L-6-v2。
- 工作原理:与生成嵌入向量的“双编码器”不同,交叉编码器将查询和候选文档同时输入模型,直接输出一个相关性分数。这种方式计算量更大,但准确度远高于仅比较向量相似度。
- 性能影响:重排序会增加延迟(约几十到几百毫秒,取决于模型大小和硬件)。因此,它通常只作用于第一轮检索返回的较多结果(如50个),然后从中筛选出最相关的少数几个(如5个)送给LLM。
- 配置:重排序器也以
Nx.Serving运行,配置方式与嵌入器类似。你可以根据硬件能力选择更大或更小的模型。
4.5 知识图谱(GraphRAG):超越向量搜索
这是Arcana的高级功能。它不仅仅存储文本片段,还尝试从文档中提取实体(人物、地点、概念)和关系,构建一个知识图谱。
它能解决什么问题?假设你的知识库包含大量关于“Doctor Who”剧集的信息。一个问题是:“哪些角色既是Master的盟友,又是Doctor的敌人?” 纯向量搜索可能很难直接找到答案,因为它需要理解“盟友”和“敌人”这种复杂关系,并跨多个实体进行推理。
GraphRAG的工作流程:
- 实体提取:在文档灌库时,使用一个NER(命名实体识别)模型或LLM,从文本中提取实体。
- 关系链接:将不同文档中提到的同一实体进行链接(例如,“博士”和“The Doctor”是同一个实体)。
- 社区检测:使用Leiden等算法,在知识图谱中发现紧密连接的子图(社区)。每个社区可以自动生成一个摘要。
- 图谱搜索:当用户查询时,除了进行向量/全文搜索,还可以在图谱中搜索相关的实体和社区。图谱的结果可以与向量搜索结果进行融合。
如何使用:
# 在灌库时启用图谱构建 Arcana.ingest(document_text, repo: MyApp.Repo, enable_graph: true) # 在搜索时启用图谱融合 {:ok, results} = Arcana.search(query, repo: MyApp.Repo, graph_fusion: true)图谱构建是计算密集型的,通常建议在后台异步进行(Arcana路线图中的Async ingestion via Oban正是为此)。对于动态性不强的知识库,可以定期全量更新图谱。
5. 实战:从零构建一个产品知识库助手
让我们通过一个完整的例子,将一个产品的Markdown文档库变成一个可问答的助手。
5.1 环境准备与初始化
首先,确保你的Phoenix项目已经配置了PostgreSQL并安装了pgvector扩展。
安装Arcana:
# 使用Igniter(推荐) mix igniter.install arcana mix ecto.migrate # 或手动安装 # 1. 添加依赖 `{:arcana, “~> 0.1”}` 到 mix.exs # 2. 运行 `mix deps.get` # 3. 运行 `mix arcana.gen.migrations` # 4. 运行 `mix ecto.migrate`配置监督树(
lib/my_app/application.ex):defmodule MyApp.Application do use Application @impl true def start(_type, _args) do # 定义你的嵌入和重排序服务 children = [ # ... 你应用的其他子进程 {Nx.Serving, serving: Arcana.Embedder.Bumblebee.default_serving(“BAAI/bge-small-en-v1.5”), name: Arcana.Embedder.Bumblebee }, {Nx.Serving, serving: Arcana.Reranker.Bumblebee.default_serving(“cross-encoder/ms-marco-MiniLM-L-6-v2”), name: Arcana.Reranker.Bumblebee }, MyApp.Repo, # Arcana的顶级监督者 Arcana.Supervisor ] opts = [strategy: :one_for_one, name: MyApp.Supervisor] Supervisor.start_link(children, opts) end end配置LLM提供商(
config/config.exs):config :arcana, :llm_providers, %{ openai: [ api_key: System.get_env(“OPENAI_API_KEY”), # 可选:设置不同的模型用于不同任务 chat_model: “gpt-4o-mini”, embedding_model: “text-embedding-3-small” ], # 你也可以配置本地模型,如通过Ollama ollama: [ base_url: “http://localhost:11434”, chat_model: “llama3.2” ] }
5.2 灌库:将文档转化为知识
假设你的产品文档存放在priv/docs目录下,是一系列Markdown文件。
# lib/my_app/knowledge/importer.ex defmodule MyApp.Knowledge.Importer do alias Arcana.Document def import_from_directory(path, collection_name) do path |> Path.join(“**/*.md”) |> Path.wildcard() |> Task.async_stream(&import_file(&1, collection_name), max_concurrency: 4, timeout: 30_000 ) |> Stream.run() end defp import_file(file_path, collection) do content = File.read!(file_path) metadata = %{ source: file_path, title: Path.basename(file_path, “.md”), last_modified: File.stat!(file_path).mtime } # 关键:调用Arcana.ingest case Arcana.ingest(content, repo: MyApp.Repo, collection: collection, metadata: metadata, # 启用图谱构建(首次灌库或全量更新时使用) enable_graph: true ) do {:ok, _doc} -> IO.puts(“[OK] Ingested: #{file_path}”) {:error, reason} -> IO.puts(“[ERROR] Failed #{file_path}: #{inspect(reason)}”) end end end然后,可以在一个Mix任务或IEx会话中运行:
MyApp.Knowledge.Importer.import_from_directory(“priv/docs”, “product-manual”)灌库注意事项:
- 增量更新:
Arcana.ingest会根据文档内容的哈希值进行去重。如果你更新了一个文件,重新灌库会自动更新对应的向量和索引。 - 元数据利用:
metadata字段非常重要!你可以在其中存储文档标题、作者、版本、标签等信息。在搜索时,你可以利用这些元数据进行过滤,例如只搜索某个特定版本的手册。 - 集合(Collection):使用
collection参数对文档进行逻辑分组。这在多租户应用或拥有多个独立知识库的场景下非常有用。
5.3 构建问答接口
现在,我们可以在一个Phoenix上下文或控制器中暴露问答功能。
# lib/my_app/knowledge/assistant.ex defmodule MyApp.Knowledge.Assistant do @repo MyApp.Repo def ask(question, user_id \\ nil) do # 构建查询上下文,可以附加用户信息作为元数据 query_context = %{user_id: user_id, timestamp: DateTime.utc_now()} case Arcana.ask(question, repo: @repo, collection: “product-manual”, # 限定在特定集合 llm: “openai:gpt-4o-mini”, rerank: true, # 启用重排序 max_chunks: 5, # 最多送5个片段给LLM context: query_context # 可选:传入上下文,可用于遥测或自定义逻辑 ) do {:ok, answer} -> # answer是一个%Arcana.Answer{}结构体 %{ text: answer.text, citations: Enum.map(answer.chunks, & &1.metadata[:source]), confidence: answer.score # 如果LLM提供商支持 } {:error, reason} -> {:error, “Failed to get answer: #{inspect(reason)}”} end end def search(query, filters \\ %{}) do # 更底层的搜索,可以应用元数据过滤 search_options = [ repo: @repo, collection: “product-manual”, filters: filters, # 例如: %{“version” => “2.0”} hybrid_weights: %{vector: 0.6, full_text: 0.4}, limit: 10 ] case Arcana.search(query, search_options) do {:ok, results} -> Enum.map(results, fn chunk -> %{ content: chunk.text, source: chunk.metadata[:source], relevance: chunk.score } end) {:error, reason} -> {:error, reason} end end end5.4 集成到LiveView
最后,创建一个简单的LiveView界面。
# lib/my_app_web/live/assistant_live.ex defmodule MyAppWeb.AssistantLive do use MyAppWeb, :live_view @impl true def mount(_params, _session, socket) do {:ok, assign(socket, query: “”, answer: nil, loading: false, history: [])} end @impl true def handle_event(“ask”, %{“query” => query}, socket) when query != “” do send(self(), {:run_query, query}) {:noreply, assign(socket, loading: true, query: query)} end @impl true def handle_info({:run_query, query}, socket) do # 在后台任务中执行,避免阻塞LiveView进程 answer = MyApp.Knowledge.Assistant.ask(query) history = [%{query: query, answer: answer} | socket.assigns.history] {:noreply, assign(socket, answer: answer, loading: false, history: history)} end @impl true def render(assigns) do ~H””” <div class=“max-w-4xl mx-auto p-6”> <h1 class=“text-3xl font-bold mb-8”>产品知识助手</h1> <.form for={:query} phx-submit=“ask” class=“mb-6”> <div class=“flex gap-2”> <input type=“text” name=“query” value={@query} placeholder=“请问关于产品的任何问题…” class=“flex-1 p-3 border rounded-lg” disabled={@loading} /> <button type=“submit” class=“bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 disabled:opacity-50” disabled={@loading} > <%= if @loading, do: “思考中…”, else: “提问” %> </button> </div> </.form> <%= if @answer do %> <div class=“bg-gray-50 p-6 rounded-lg mb-6”> <h2 class=“text-xl font-semibold mb-2”>回答:</h2> <p class=“whitespace-pre-wrap”><%= @answer.text %></p> <%= if @answer.citations != [] do %> <div class=“mt-4 text-sm text-gray-600”> <h3 class=“font-medium”>参考来源:</h3> <ul class=“list-disc pl-5 mt-1”> <%= for citation <- @answer.citations do %> <li><%= citation %></li> <% end %> </ul> </div> <% end %> </div> <% end %> <%= if @history != [] do %> <div class=“mt-8”> <h2 class=“text-2xl font-bold mb-4”>历史记录</h2> <%= for item <- @history do %> <div class=“border-b py-4”> <p class=“font-medium”>Q: <%= item.query %></p> <p class=“mt-1 text-gray-700”>A: <%= item.answer.text %></p> </div> <% end %> </div> <% end %> </div> “”” end end6. 性能调优、监控与问题排查
一个生产级的RAG系统离不开性能调优和监控。
6.1 性能调优要点
数据库索引:Arcana的迁移文件会创建必要的索引(如向量索引
ivfflat或hnsw,全文搜索索引GIN)。但你需要根据数据量调整索引参数。-- 对于大规模数据集,考虑使用HNSW索引(pgvector>=0.7.0) CREATE INDEX ON embeddings USING hnsw (vector vector_cosine_ops) WITH (m=16, ef_construction=64); -- 在config/config.exs中配置Arcana使用hnsw config :arcana, Arcana.Store.Postgres, index_type: :hnsw缓存策略:
- 查询缓存:对频繁出现的、结果不变的查询(如常见问题FAQ),可以在应用层(如
Cachex)缓存最终的答案。 - 嵌入缓存:对于固定的文档块,其向量是静态的。Arcana内部已做了缓存,但确保你的
Nx.Serving进程是持久化的。
- 查询缓存:对频繁出现的、结果不变的查询(如常见问题FAQ),可以在应用层(如
LLM调用优化:
- 设置超时和重试:在LLM提供商配置中设置合理的超时时间,并实现重试逻辑(Arcana的LLM适配器通常支持)。
- 使用更便宜的模型进行路由:在
Arcana.Loop中,使用廉价快速的模型(如gpt-4o-mini)作为控制器,而用更强大的模型(如gpt-4o)仅用于生成最终答案。
6.2 监控与可观测性
Arcana通过:telemetry发射了大量事件。集成到Phoenix LiveDashboard非常简单。
添加遥测仪表盘:
# lib/my_app_web/telemetry.ex defp arcana_metrics do [ # 检索耗时分布 summary(“arcana.search.duration”), # 嵌入耗时 summary(“arcana.embedder.duration”), # LLM调用耗时和token计数 summary(“arcana.llm.chat.duration”), counter(“arcana.llm.chat.tokens.prompt”), counter(“arcana.llm.chat.tokens.completion”), # 重排序耗时 summary(“arcana.reranker.duration”), ] end在LiveDashboard中查看:这些指标会自动出现在你的Phoenix LiveDashboard的“Metrics”页签下。
自定义事件处理:你可以附加处理器来记录日志或发送到外部监控系统。
:telemetry.attach( “arcana-answer-logger”, [:arcana, :answer, :stop], fn _event, measurements, _meta, _config -> Logger.info(“[Arcana] Answer generated in #{measurements.duration}ms”) end, nil )
6.3 常见问题排查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 检索结果完全不相关 | 1. 嵌入模型不匹配(中英文混用等) 2. 分块策略不合理 3. 向量索引未正确构建或需要优化 | 1. 检查嵌入模型是否适合你的文本语言。尝试更换模型(如从bge-small-en换为bge-small-zh)。2. 检查返回的 chunk内容,看分块是否破坏了语义。调整chunk size和overlap。3. 对于大量数据,确保 pgvector索引已创建并尝试VACUUM ANALYZE;。考虑调整索引参数(如ivfflat的lists数)。 |
| LLM回答出现“幻觉”,编造信息 | 1. 检索到的上下文不足或无关 2. LLM本身过于“创造性” 3. 未启用事实性核查(Grounding) | 1. 增加max_chunks参数,给LLM更多上下文。检查检索结果的相关性分数,过滤掉过低分数的片段。2. 在LLM系统提示词(System Prompt)中强调“仅基于提供的上下文回答”。Arcana的 Answerer模块已内置此提示。3. 启用 Pipeline.ground/2或Loop.ground/1步骤,对答案进行自然语言推理(NLI)验证,并过滤掉低置信度的答案。 |
| 查询响应速度慢 | 1. 向量搜索未走索引 2. 重排序模型过大 3. LLM API延迟高 4. 网络或数据库负载高 | 1. 使用EXPLAIN ANALYZE分析查询计划,确认使用了向量索引。2. 尝试更小的重排序模型(如 cross-encoder/ms-marco-MiniLM-L-2-v2),或降低重排序的候选集大小(rerank_candidates)。3. 考虑使用更快的LLM模型,或实现LLM响应的流式输出以提升用户体验。 4. 监控数据库和网络资源。 |
| 灌库过程内存溢出或极慢 | 1. 单次灌库文档过大或过多 2. 嵌入模型批处理设置不当 | 1. 将大文档拆分成更小的逻辑单元后再灌库。使用Task.async_stream限制并发数(如示例中的max_concurrency: 4)。2. 确保 Nx.Serving配置了合适的批处理大小。对于本地模型,批处理能极大提升吞吐,但过大的批次可能导致OOM。 |
Arcana.Loop陷入无限循环或执行奇怪操作 | 1. 控制器LLM(controller_llm)能力不足或提示词不佳2. max_iterations设置过高3. 工具描述不够清晰 | 1. 尝试更强的控制器模型。检查Arcana默认的系统提示词,看是否需要根据你的领域进行微调(可通过配置覆盖)。 2. 合理设置 max_iterations(通常3-5步足够)。3. 确保工具( search,answer)的描述清晰指明了其用途和调用条件。 |
7. 评估与迭代:如何衡量你的RAG系统好坏
构建RAG系统不是一劳永逸的,需要持续的评估和迭代。Arcana提供了一些内置的评估工具和模式。
1. 构建测试集: 创建一个包含“问题-标准答案-相关文档ID”的测试集(例如,一个JSON文件)。问题应覆盖你的知识库的各种查询类型。
2. 使用评估流水线: Arcana的Pipeline模式非常适合自动化评估。你可以编写一个脚本,用测试集中的每个问题调用Pipeline,并收集关键指标。
defmodule MyApp.Evaluation.Runner do alias Arcana.Pipeline def evaluate(test_cases, repo) do test_cases |> Enum.map(fn %{question: q, expected_chunk_ids: expected_ids} -> ctx = Pipeline.new(q, repo: repo) |> Pipeline.search() |> Pipeline.rerank() retrieved_ids = Enum.map(ctx.searches, & &1.chunk_id) # 计算指标 %{ question: q, retrieved: retrieved_ids, expected: expected_ids, recall: calculate_recall(retrieved_ids, expected_ids), mrr: calculate_mrr(retrieved_ids, expected_ids) } end) end defp calculate_recall(retrieved, expected) do # 计算召回率:检索到的相关文档占所有相关文档的比例 intersection = MapSet.intersection(MapSet.new(retrieved), MapSet.new(expected)) MapSet.size(intersection) / max(MapSet.size(MapSet.new(expected)), 1) end defp calculate_mrr(retrieved, expected) do # 计算平均倒数排名:第一个相关文档排名的倒数 first_relevant_rank = Enum.find_index(retrieved, fn id -> id in expected end) |> case do nil -> 0 index -> 1 / (index + 1) end first_relevant_rank end end3. 核心评估指标:
- 召回率(Recall@K):在前K个检索结果中,包含了多少比例的标准答案相关文档。衡量检索的全面性。
- 平均倒数排名(MRR):第一个相关文档的排名的倒数的平均值。衡量系统是否能将最相关的文档排在最前面。
- 命中率(Hit@K):前K个结果中至少包含一个相关文档的查询所占的比例。这是一个更宽松的指标。
4. 迭代改进: 根据评估结果,你可以有针对性地调整:
- 召回率低:尝试调整混合搜索权重、优化分块策略、引入查询扩展(
Pipeline.expand)。 - MRR低:尝试启用或调整重排序器、使用更强大的嵌入模型、优化查询改写(
Pipeline.rewrite)。 - 答案质量差:在评估中加入对生成答案的忠实度(Faithfulness)和相关性(Relevance)的人工或自动化评分(可借助
Pipeline.ground或外部工具如RAGAS)。
我个人在实际部署中的体会是,建立一个哪怕只有50-100个问题的小型测试集,并定期(例如每周)运行自动化评估,对于防止系统在迭代中“性能回退”至关重要。每次更改嵌入模型、分块参数或搜索策略后,跑一遍测试集,用数据说话,而不是凭感觉。Arcana的模块化设计使得这种A/B测试变得非常容易——你可以同时配置两套不同的Pipeline(例如,一套用本地模型,一套用OpenAI),并用同一个测试集来对比它们的性能和成本。
