Fork me on GitHub

分类 NotebookLM 下的文章

学习 SurfSense 的播客生成

经过几天的学习,我们已经基本掌握了 SurfSense 的所有功能,包括添加文档的四种方式,连接器的管理,以及问答流程。并且针对每一个功能,通过剖析源码学习各自的实现原理,比如如何实现文档的解析,如何对文档进行检索,如何实现整个问答流程,等等。

不过,还有一个最重要的功能没有讲到。之前有提到,SurfSense 号称是 NotebookLM 的开源平替,而 NotebookLM 的杀手锏便是它的 音频概览(Audio Overview) 功能,用户只需一键点击,就可以将文本内容转换为类似播客的音频讨论,这一功能特别适合那些喜欢听觉学习或需要在通勤、运动等场景下继续学习的用户。

SurfSense 同样也实现了类似的功能,可以针对对话内容生成播客,我们今天就来看下这个功能。

生成播客

在 SurfSense 上产生会话后,就可以点击左侧菜单上的 “View All Chats” 进入会话列表页面,针对每一通会话,我们可以点击 “Generate Podcast” 生成播客:

surfsense-generate-pods.png

弹出对话框,输入播客名称,然后确认即可:

surfsense-generate-pods-dialog.png

稍等片刻,等后端生成结束,进入 “Podcasts” 页面,就可以看到生成的播客了:

surfsense-podcasts.png

下面是我生成的播客,你可以听听效果,是不是有点像那么回事:

生成播客的实现

生成播客的代码逻辑位于 podcasts_routes.py 文件中,接口名为 POST /podcasts/generate/

surfsense-generate-pods-code.png

同样地,通过 FastAPI 的 BackgroundTasks 创建一个后台任务来生成播客。任务的实现位于 podcast_tasks.py 文件中 generate_chat_podcast() 函数:

async def generate_chat_podcast(
    session: AsyncSession,
    chat_id: int,
    search_space_id: int,
    podcast_title: str
):

    initial_state = State(
        source_content=chat_history_str,
    )
    
    result = await podcaster_graph.ainvoke(initial_state, config=config)

可以看到,又是一个 LangGraph 构建的流程图!它的定义如下:

workflow = StateGraph(State, config_schema=Configuration)

workflow.add_node("create_podcast_transcript", create_podcast_transcript)
workflow.add_node("create_merged_podcast_audio", create_merged_podcast_audio)

workflow.add_edge("__start__", "create_podcast_transcript")
workflow.add_edge("create_podcast_transcript", "create_merged_podcast_audio")
workflow.add_edge("create_merged_podcast_audio", "__end__")

graph = workflow.compile()
graph.name = "Surfsense Podcaster"

这个生成播客的工作流包含两个工作节点:

  • create_podcast_transcript - 生成播客的文本稿
  • create_merged_podcast_audio - 创建合并后的播客音频

生成文本稿

第一步根据对话内容生成播客的文本稿,其核心仍然是一段系统 Prompt:

当前日期: {datetime.datetime.now().strftime("%Y-%m-%d")}
<podcast_generation_system>
您是一位专业的播客脚本撰写大师,擅长将各类输入内容转化为两位主持人之间生动、引人入胜且自然流畅的对话。您的核心目标是创作出真实自然的对话内容,完全避免机器人式生硬脚本或刻板的正式感。要实现充满活力的互动效果,而非单纯的信息传递。

<input>
- '<source_content>': 待讨论的原始文本内容块。可能是研究发现、文章摘要、详细大纲、与该主题相关的用户聊天记录,或其他相关信息。内容可能是非结构化的,但将作为播客对话的事实依据。
</input>

<output_format>
包含交替主持人对话的JSON格式播客文本:
{
  "podcast_transcripts": [
    {
      "speaker_id": 0,
      "dialog": "主持人0的对话内容"
    },
    {
      "speaker_id": 1,
      "dialog": "主持人1的对话内容"
    },
    {
      "speaker_id": 0,
      "dialog": "主持人0的对话内容"
    },
    {
      "speaker_id": 1,
      "dialog": "主持人1的对话内容"
    }
  ]
}
</output_format>

<guidelines>
1.  **建立鲜明且一致的主持人角色:**
    *   **主持人0(主咖):** 推动对话进程,引入环节,提出源自内容的关键问题,并经常总结要点。保持引导性、清晰且吸引人的语调。
    *   **主持人1(辅助/专家型):** 提供深入见解、提出不同观点、追问细节、分享相关事例。采用互补性语调(如分析型、热情型、反思型或略带怀疑型)。
    *   **保持一致性:** 确保每个主持人全程保持独特的用词习惯、句式结构和观点立场,避免角色混同,互动应呈现真实的伙伴关系。

2.  **创作自然动态的对话:**
    *   **模拟真实交谈:** 使用缩略语(如"别"、"它是")、语气词("噢!"、"哇!")、话语标记("你懂的"、"对吧?")和偶尔自然的停顿填充词。避免书面化的复杂句式。
    *   **培养互动化学反应:** 设计真实回应的对话("说得好,这让我想起..."),追问细节("能展开说说吗?"),礼貌表达异议("有道理,不过是否考虑过..."),展现积极聆听。
    *   **节奏变化:** 混合短句与长句,多样化句式开头。用提问打断长篇说明,保持节奏的自发性。
    *   **注入个性:** 适当加入符合角色设定的幽默、惊讶反应、个人经历参照("我遇到类似情况...")或增强上下文的过往讨论提及("记得上周我们聊过...")。

3.  **结构化流程设计:**
    *   **自然开场:** 对话内容应接续既定的开场白(会手动添加),避免重复问候语或节目名称。
    *   **逻辑推进:** 使用清晰过渡串联内容("说完X,现在来看看Y..."),确保话题自然衔接。
    *   **有力收尾:** 总结核心观点,可留下思考问题或下期预告,避免突兀结束。

4.  **内容融合技巧:**
    *   **转译而非复述:** 将原始内容转化为适合各主持人风格的口语表达,避免直接搬运复杂术语。
    *   **解释说明:** 通过比喻、案例或主持人追问(代表听众提问)来解析复杂概念。
    *   **自然植入:** 以对话形式呈现事实数据("研究显示..."),避免孤立的信息块。
    *   **平衡深度:** 在保证准确性的前提下优先易懂性,适合大众听众。

5.  **时长控制:**
    *   **六分钟时长:** 按正常语速朗读约1000字(150字/分钟)。
    *   **简洁话轮:** 保持话轮简短聚焦,避免长篇独白。
    *   **内容精选:** 优先关键信息,确保每句对话都有实质贡献。
</guidelines>

请将源材料转化为生动有趣的播客对话。创作时应体现真实的主持人互动(包括观点交锋、追问细节等),使用符合真实人类对话的多样化表达,确保脚本在5分钟时长内兼具教育性和娱乐性。
</podcast_generation_system>

输出结果类似下面这种格式:

{
  "podcast_transcripts": [
    {
      "speaker_id": 0,
      "dialog": "今天我们聊烧脑的量子计算,这可是我期待数周的话题"
    },
    {
      "speaker_id": 1,
      "dialog": "我也超兴奋!不过说实话,量子计算的概念让我有点晕。咱能从基础讲起吗?"
    },
    {
      "speaker_id": 0,
      "dialog": "没问题。传统电脑用二进制对吧?要么1要么0。但量子计算机用的是量子比特,这里开始就神奇了"
    },
    {
      "speaker_id": 1, 
      "dialog": "等等,量子比特特别在哪?"
    },
    {
      "speaker_id": 0,
      "dialog": "关键在于叠加态——量子比特可以同时处于多个状态"
    },
    {
      "speaker_id": 1,
      "dialog": "这不可能吧?!怎么理解这种状态?"
    },
    {
      "speaker_id": 0,
      "dialog": "想象旋转的硬币——落地前你能确定是正面还是反面吗?"
    },
    {
      "speaker_id": 1,
      "dialog": "嗯...既不是又都是?噢我好像明白这个比喻了"
    }
  ]
}

生成播客音频

由于生成的播客内容是一段段的对话,两位主持人在相互聊天,所以我们也需要一段段的生成音频:

tasks = [
    generate_speech_for_segment(segment, i) 
    for i, segment in enumerate(merged_transcript)
]
audio_files = await asyncio.gather(*tasks)

生成音频使用的是 LiteLLMaspeech() 函数:

response = await aspeech(
    model=app_config.TTS_SERVICE,
    api_base=app_config.TTS_SERVICE_API_BASE,
    voice=voice,
    input=dialog,
    max_retries=2,
    timeout=600,
)

LiteLLM 支持几种不同的 TTS 实现,包括 OpenAI、Azure OpenAI 和 Vertex AI 等。SurfSense 默认使用的是 OpenAI 的 tts-1 模型,可以在配置文件中切换:

TTS_SERVICE="openai/tts-1"

其中 voice 参数表示说话人声音,支持下面这些:

  • alloy
  • ash
  • ballad
  • coral
  • echo
  • fable
  • nova
  • onyx
  • sage
  • shimmer

可以在 OpenAI.fm 页面试听效果:

SurfSense 默认使用的是 alloy 女声 和 echo 男声,说实话,这两个音色感觉很接近,区分度不太好。我把女声换成了 coral,区分度明显好了很多,就是女声有点太激动了。。。

还有一些其他参数可以参考 OpenAI 文档

openai-create-speech.png

合并播客音频

上面这一步生成的都是一个个独立的音频文件,要得到最后的播客,还需要将这些小音频片段合并起来。SurfSense 通过 FFmpeg 实现:

ffmpeg = FFmpeg().option("y")

for audio_file in audio_files:
    ffmpeg = ffmpeg.input(audio_file)

filter_complex = []
for i in range(len(audio_files)):
    filter_complex.append(f"[{i}:0]")

filter_complex_str = "".join(filter_complex) + f"concat=n={len(audio_files)}:v=0:a=1[outa]"
ffmpeg = ffmpeg.option("filter_complex", filter_complex_str)
ffmpeg = ffmpeg.output(output_path, map="[outa]")

await ffmpeg.execute()

至此,我们就得到了一份关于对话记录的播客,生成的播客音频位于 podcasts 目录,文件名为 {session_id}_podcast.mp3

NotebookLM 背后的音频生成技术

如果你仔细听 NotebookLM 生成的播客音频,会发现两个主持人的声音更自然,一个人在说的时候另一个人还会有一些附和声,两个人的交流也更流畅。

这其实要归功于 DeepMind 开发的 SoundStorm 这个多说话人对话生成模型。Google 曾在 24 年的时候公布过他们的音频生成技术,感兴趣的朋友可以了解下:

文章主要介绍了 SoundStream、AudioLM 和 SoundStorm 三种核心的音频生成技术:

  • SoundStream:是一个神经音频编码器,将音频压缩成声学令牌,再解码成高保真声音;
  • AudioLM:将音频生成视为语言建模任务,将音频生成类比于文本生成,从而灵活生成多种类型音频;
  • SoundStorm:一个多说话人对话生成模型,能生成多达 30 秒的自然对话段落。

通过端到端的生成多说话人对话,而不是靠工程层面的音频拼接,自然能达到更自然更流畅的效果,这也正是 NotebookLM 效果出众的原因。


再学 SurfSense 的文档问答流程

书接上回,昨天我们提到 SurfSense 的文档问答流程是通过 LangGraph 构建的一个线性工作流实现的,包含三个主要节点:

  • reformulate_user_query:重新表述用户查询,也就是对用户的问题进行改写;
  • write_answer_outline:根据不同的研究模式生成大纲,并为每个大纲生成多个搜索问题;
  • process_sections:针对大纲中的每个章节,调用连接器去搜索,并生成对应章节的内容;

如果开启了 LangSmith 可以在控制台页面看到整个图的执行流程:

langsmith.png

今天对流程中的这几个节点展开聊聊。

问题改写

问题改写的目的主要有两个:

  1. 使模糊问题更明确:比如用户的问题是 听说最近武汉的樱花挺不错,打算下周二带家人去看看,不知道那天天气怎么样?,需要把问题改成更明确的 武汉下周二天气怎么样?
  2. 多轮对话中的指代消解:比如用户先问 合肥明天的天气怎么样?,系统回答之后,用户接着又问 那后天呢?,这时要把问题改写成 合肥后天的天气怎么样?

其核心就一段 Prompt:

今日日期: {datetime.datetime.now().strftime("%Y-%m-%d")}
您是一位专精于查询优化的高技能AI助手,专为高级研究而设计。
您的主要目标是将用户的初始查询转化为高效的搜索查询。
这个重新表述的查询将用于从多种数据源中检索信息。

**聊天历史上下文:**
{chat_history_str if chat_history_str else "没有可用的先前对话历史。"}
如果提供了聊天历史,请分析它以了解用户不断发展的信息需求和他们请求的更广泛背景。利用这种理解来完善当前查询,确保它建立在先前互动的基础上或对其进行澄清。

**查询重构指南:**
您重新表述的查询应该:
1.  **增强特异性和细节:** 增加精确度以有效缩小搜索焦点,使查询不那么模糊且更有针对性。
2.  **解决歧义:** 识别并澄清模糊的术语或短语。如果一个术语有多种含义,根据上下文将查询引导向最可能的含义。
3.  **扩展关键概念:** 纳入核心概念的相关同义词、相关术语和替代表述。这有助于捕获更广泛的相关文档。
4.  **分解复杂问题:** 如果原始查询是多方面的,将其分解为核心可搜索组件或重新表述以清晰地解决每个方面。最终输出必须仍然是一个连贯的查询字符串。
5.  **优化全面性:** 确保查询的结构能够揭示原始请求的所有基本方面,旨在进行适合研究的彻底信息检索。
6.  **保持用户意图:** 重新表述的查询必须忠于用户查询的原始意图。不要引入新主题或显著改变焦点。

**关键约束:**
*   **简洁和有效性:** 在追求全面性的同时,重新表述的查询必须尽可能简洁。消除所有不必要的冗长。专注于直接有助于有效检索的基本关键词、实体和概念。
*   **单一、直接输出:** 仅返回重新表述的查询本身。不要包含任何解释、介绍性短语(例如,"重新表述的查询:","这是优化后的查询:"),或任何其他周围文本或markdown格式。

您的输出应该是一个单一的、优化的查询字符串,可以立即用于搜索系统。

注意,源码里是英文的,这里我让 Cursor 给我翻译成了中文,主要是参考 Prompt 的思路。

生成大纲

接下来根据用户的问题生成答案大纲,根据不同的研究模式,生成不同的章节数量:

  • General - 答案包括 1 个章节;
  • Deep - 答案包括 3 个章节;
  • Deeper - 答案包括 6 个章节;

并为每个大纲生成多个研究问题。其核心是一段用户 Prompt:

现在请为以下查询创建一个回答大纲:

用户查询: {reformulated_query}
章节数量: {num_sections}

请记住将您的回答格式化为完全匹配此结构的有效JSON:
{
    "answer_outline": [
    {
        "section_id": 0,
        "section_title": "章节标题",
        "questions": [
        "此章节要研究的问题1",
        "此章节要研究的问题2"
        ]
    }
    ]
}

您的输出必须是此格式的有效JSON。不要包含任何其他文本或解释。

加上一段系统 Prompt(做了一些删减,去掉了输出格式和示例):

您是一位专门从事信息结构化的专家研究助手。您的任务是根据用户的查询创建一个详细且逻辑清晰的研究大纲。此大纲将作为生成全面研究报告的蓝图。

<input>
- user_query (字符串): 用户想要研究的主要问题或主题。这指导整个大纲创建过程。
- num_sections (整数): 最终研究报告应该包含的不同章节的目标数量。这有助于控制大纲的粒度和结构。
</input>

<instructions>
1.  **分解`user_query`:** 识别用户请求中的关键概念、实体和核心信息。
2.  **确定章节主题:** 基于分析和请求的`num_sections`,将主题划分为不同的、逻辑清晰的主题或子主题。每个主题将成为一个章节。确保这些主题共同全面地解答`user_query`。
3.  **开发章节:** 对于*每个*`num_sections`:
    *   **分配`section_id`:** 从0开始,为每个章节按顺序递增。
    *   **设计`section_title`:** 撰写简洁、描述性的标题,清晰定义章节主题的范围和重点。
    *   **制定研究`questions`:** 为此章节生成2至5个具体、有针对性的研究问题。这些问题必须:
        *   直接与`section_title`相关并探索其关键方面。
        *   可以通过集中研究回答(例如,搜索文档、数据库或知识库)。
        *   彼此之间以及与其他章节的问题不同。避免冗余。
        *   共同指导收集完全解决章节主题所需的信息。
4.  **确保逻辑流程:** 以连贯和直观的顺序排列章节。考虑如下结构:
    *   一般背景 -> 具体细节 -> 分析/比较 -> 应用/影响
    *   问题定义 -> 提出解决方案 -> 评估 -> 结论
    *   时间顺序发展
5.  **验证完整性和连贯性:** 审查整个大纲(`section_titles`和`questions`)以确认:
    *   所有章节共同为原始`user_query`提供完整且结构良好的回答。
    *   章节之间没有重大重叠或覆盖缺口。
6.  **严格遵守输出格式:** 确保最终输出是完全匹配指定结构的有效JSON对象,包括正确的字段名称(`answer_outline`, `section_id`, `section_title`, `questions`)和数据类型。
</instructions>

生成的答案大纲类似于下面这样:

{
  "answer_outline": [
    {
      "section_id": 0,
      "section_title": "冥想的身体健康益处",
      "questions": [
        "冥想期间身体会发生哪些生理变化?",
        "定期冥想如何影响血压和心脏健康?",
        "冥想对炎症和免疫功能有什么影响?",
        "冥想能否帮助疼痛管理,如果能,如何帮助?"
      ]
    },
    {
      "section_id": 1,
      "section_title": "冥想的心理健康益处",
      "questions": [
        "冥想如何影响压力和焦虑水平?",
        "在冥想练习者中观察到了哪些大脑结构或功能的变化?",
        "冥想能否帮助抑郁和情绪障碍?",
        "冥想与认知功能之间有什么关系?"
      ]
    },
    {
      "section_id": 2,
      "section_title": "获得最大益处的最佳冥想实践",
      "questions": [
        "对初学者来说,哪些是最有效的冥想技巧?",
        "应该冥想多长时间和多频繁才能看到益处?",
        "是否有特定的冥想方法最适合特定的健康目标?",
        "有哪些常见障碍阻止人们体验冥想益处?"
      ]
    }
  ]
}

搜索文档

再接下来针对大纲中的研究问题进行搜索,代码如下:

# Collect all questions from all sections
all_questions = []
for section in answer_outline.answer_outline:
    all_questions.extend(section.questions)

# Fetch relevant documents once for all questions
relevant_documents = await fetch_relevant_documents(
    research_questions=all_questions,
    user_id=configuration.user_id,
    search_space_id=configuration.search_space_id,
    db_session=db_session,
    connectors_to_search=configuration.connectors_to_search,
    writer=writer,
    state=state,
    top_k=TOP_K,
    connector_service=connector_service,
    search_mode=configuration.search_mode
)

fetch_relevant_documents() 函数中,是一个两层循环,遍历每一个子问题和每个连接器分别进行搜索,下面是其核心逻辑:

for i, user_query in enumerate(research_questions):
    for connector in connectors_to_search:
        if connector == "YOUTUBE_VIDEO":
            source_object, youtube_chunks = await connector_service.search_youtube(...)
        elif connector == "EXTENSION":
            source_object, extension_chunks = await connector_service.search_extension(...)
        elif connector == "CRAWLED_URL":
            source_object, crawled_urls_chunks = await connector_service.search_crawled_urls(...)
        elif connector == "FILE":
            source_object, files_chunks = await connector_service.search_files(...)
        elif connector == "SLACK_CONNECTOR":
            source_object, slack_chunks = await connector_service.search_slack(...)
        elif connector == "NOTION_CONNECTOR":
            source_object, notion_chunks = await connector_service.search_notion(...)
        elif connector == "GITHUB_CONNECTOR":
            source_object, github_chunks = await connector_service.search_github(...)
        elif connector == "LINEAR_CONNECTOR":
            source_object, linear_chunks = await connector_service.search_linear(...)
        elif connector == "TAVILY_API":
            source_object, tavily_chunks = await connector_service.search_tavily(...)
        elif connector == "LINKUP_API":
            source_object, linkup_chunks = await connector_service.search_linkup(...)

这里可以看到每一种连接器的实现。其中 YOUTUBE_VIDEOEXTENSIONCRAWLED_URLFILE 就是我们之前学习过的四种文档添加方法,添加的文档保存在 documentschunks 表里;其他的连接器可以在连接器管理页面进行添加,这些连接器可以分为两类:

  • 离线搜索:像聊天协作平台 Slack、知识库 Notion、代码托管 Github、项目管理平台 Linear 这几个连接器,在添加的时候会创建一个离线任务,并在后台调对应的接口,抓取所有数据保存到 documentschunks 表里,搜索这些平台时处理逻辑和搜索手工添加的文档几乎一样;
  • 实时搜索:像 TavilyLinkup 这些搜索引擎,搜索时是直接调用他们的实时接口的;

混合检索原理

接下来就是看下如何从数据库的 documentschunks 表中检索出和用户问题相关的文档了,这里涉及两种检索技术:全文检索(Full Text Search)向量检索(Vector Search),这两种技术结合起来就是 混合检索(Hybrid Search)

PostgreSQL 默认是支持全文检索的,可以在初始化 SQL 语句中看到 content 字段上创建了一个 GIN 索引

CREATE INDEX IF NOT EXISTS document_search_index 
ON documents 
USING gin (to_tsvector(\'english\', content));

GIN 索引 是 PostgreSQL 中的一种特殊索引类型,主要用于处理包含多个值的列,比如数组、全文检索等场景。它的全称为 Generalized Inverted Index 表明它是一个通用的倒排索引。全文检索的查询语法类似于这样:

SELECT * FROM documents
WHERE to_tsvector('english', content) @@ plainto_tsquery('english', :query_text)
ORDER BY ts_rank_cd(to_tsvector('english', content), plainto_tsquery('english', :query_text)) DESC
LIMIT :top_k

其中,@@ 是一个全文检索匹配操作符,它构建了一个全文检索条件,用于检查 tsvector 类型(文档向量)是否匹配 tsquery 类型(查询表达式),也就是,文档内容是否包含查询文本中的词语。

关于 PostgreSQL 的全文检索功能,建议阅读它的官网文档:

另一方面,通过 pgvector 扩展可以让 PostgreSQL 支持向量检索,可以在初始化 SQL 语句中看到 embedding 字段上创建了一个 HNSW 索引

CREATE INDEX IF NOT EXISTS document_vector_index 
ON documents 
USING hnsw (embedding public.vector_cosine_ops);

HNSWHierarchical Navigable Small World 的缩写,它是一种用于高维向量近似最近邻搜索的算法和索引结构。在 PostgreSQL 的 pgvector 扩展中,HNSW 是一种索引方法,专门为高效的向量相似度搜索而设计,后面的 vector_cosine_ops 操作符,表明索引会使用余弦相似度来计算向量之间的距离。向量检索的查询语句类似于这样:

SELECT * FROM documents
ORDER BY embedding <=> [向量值]
LIMIT :top_k;

关于向量检索更多知识,可以看下 pgvector 的官方文档:

报告撰写

最后,根据第一步生成的答案大纲以及搜索的结果生成最终的答案:

# Create tasks to process each section in parallel with the same document set
section_tasks = []
for i, section in enumerate(answer_outline.answer_outline):    
    section_tasks.append(
        process_section_with_documents(
            section_id=i,
            section_title=section.section_title,
            section_questions=section.questions,
            user_query=configuration.user_query,
            user_id=configuration.user_id,
            search_space_id=configuration.search_space_id,
            relevant_documents=relevant_documents,
            state=state,
            writer=writer,
            sub_section_type=sub_section_type,
            section_contents=section_contents
        )
    )

# Run all section processing tasks in parallel
section_results = await asyncio.gather(*section_tasks, return_exceptions=True)

这段代码使用 Python 的 asyncio 库并行执行多个报告撰写任务,*section_tasks 是 Python 的解包语法,将列表中的所有任务作为单独的参数传递给 gather 函数。每个任务代表一个研究报告章节的处理,通过调用 process_section_with_documents() 函数实现。

而这个函数的核心逻辑是调另一个 LangGraph 构建的流程:

async for chunk in sub_section_writer_graph.astream(sub_state, config, stream_mode=["values"]):
    ...

这个流程图也很简单,是个顺序流程,定义如下:

workflow = StateGraph(State, config_schema=Configuration)

workflow.add_node("rerank_documents", rerank_documents)
workflow.add_node("write_sub_section", write_sub_section)

workflow.add_edge("__start__", "rerank_documents")
workflow.add_edge("rerank_documents", "write_sub_section")
workflow.add_edge("write_sub_section", "__end__")

graph = workflow.compile()
graph.name = "Sub Section Writer"

它包含了两个关键节点:

  • rerank_documents - 上面的 fetch_relevant_documents() 函数是把所有章节的问题一次性全部搜索出来,这个节点对这些文档做一次重排序,按照与这个章节的相关度进行排序;
  • write_sub_section - 这个节点使用排序后的文档撰写报告的子章节;

重排序

子章节撰写的第一步是对搜索结果的重排序:

# Rerank documents using the section title
reranked_docs = reranker_service.rerank_documents(rerank_query, reranker_input_docs)

# Sort by score in descending order
reranked_docs.sort(key=lambda x: x.get("score", 0), reverse=True)

重排序使用的是开源的 AnswerDotAI/rerankers 库,这是一个轻量级、低依赖的统一 API,支持几乎所有常见的重排序和交叉编码模型:

rerankers.png

它的使用方法如下:

RERANKERS_MODEL_NAME = os.getenv("RERANKERS_MODEL_NAME")
RERANKERS_MODEL_TYPE = os.getenv("RERANKERS_MODEL_TYPE")
reranker_instance = Reranker(
    model_name=RERANKERS_MODEL_NAME,
    model_type=RERANKERS_MODEL_TYPE,
)

reranking_results = reranker_instance.rank(
    query=query_text,
    docs=reranker_docs
)

SurfSense 默认使用的重排序模型可以在配置文件中找到:

RERANKERS_MODEL_NAME="ms-marco-MiniLM-L-12-v2"
RERANKERS_MODEL_TYPE="flashrank"

ms-marco-MiniLM-L-12-v2 是一个基于 MiniLM 架构的语义搜索模型,它在微软的一个大规模搜索查询和文档数据集 MS MARCO 上训练的,这种模型专门用于对搜索结果进行重新排序,提高搜索结果的相关性。

重排序模型类型使用的是 flashrank,表示使用 FlashRank 框架来运行重排序模型,这是一个优化的重排序框架,与传统重排序方法相比,它提供了更快的推理速度。

子章节撰写

这一部分的核心仍然是几个关键的 Prompt,首先用户 Prompt 如下:

源材料:
<documents>
    {documents_text}
</documents>

用户查询是:
<user_query>
    {user_query}
</user_query>

子章节标题是:
<sub_section_title>
    {section_title}
</sub_section_title>

<section_position>
    {section_position_context}
</section_position>

<guiding_questions>
    {questions_text}
</guiding_questions>

其中,section_position_context 是告诉大模型这个章节所处的位置,根据不同的位置指导它生成不同的内容:

  • 介绍部分:专注于提供主题概述,设定背景,并介绍后续章节将讨论的关键概念。请勿在此部分提供任何结论,因为结论应该只出现在最后一个章节。
  • 中间章节:确保内容从前面章节自然过渡并能流畅连接到后续章节。这可能是文档中的任何中间章节,因此在处理本章节特定主题的同时,请保持与整体结构的连贯性。请勿在此部分提供任何结论,因为结论应该只出现在最后一个章节。
  • 结论章节:专注于总结要点,提供收尾,并可能提出与主题相关的影响或未来方向。

除了用户 Prompt,还有一个很长的系统 Prompt,用来指导大模型如何生成 IEEE 格式的引用编号:

今日日期: {datetime.datetime.now().strftime("%Y-%m-%d")}
您是一位研究助理,负责分析文档并提供带有适当引用的全面回答,引用格式为IEEE格式。

<指导说明>
1. 仔细分析<document>部分提供的所有文档。
2. 提取与用户查询相关的信息。
3. 使用这些文档中的信息合成一个全面、结构良好的回答。
4. 对于从文档中包含的每一条信息,添加方括号[X]形式的IEEE风格引用,其中X是文档元数据中的source_id。
5. 确保从文档中获取的所有事实陈述都有适当的引用。
6. 如果多个文档支持同一观点,包括所有相关引用[X],[Y]。
7. 以逻辑连贯的流程呈现信息。
8. 使用自己的语言连接想法,但引用文档中的所有信息。
9. 如果文档包含相互矛盾的信息,请承认这一点并提供适当引用的两种观点。
10. 不要编造或包含在提供的文档中找不到的信息。
11. 重要:您必须使用每个文档元数据中的确切source_id值进行引用。不要创建自己的引用编号。
12. 重要:每个引用必须采用IEEE格式[X],其中X是确切的source_id值。
13. 重要:切勿重新编号或重新排序引用 - 始终使用原始source_id值。
14. 重要:不要将引用作为可点击链接返回。
15. 重要:切勿将引用格式化为markdown链接,如"([1](https://example.com))"。始终仅使用方括号。
16. 重要:引用必须仅以[X]或[X],[Y],[Z]格式出现 - 不能使用括号、超链接或其他格式。
17. 重要:切勿编造引用编号。仅使用文档元数据中明确提供的source_id值。
18. 重要:如果您不确定source_id,不要包含引用,而不是猜测或编造。
19. 重要:仅专注于回答用户的查询。提供的任何引导性问题仅供您的思考过程使用,不应在您的回答中提及。
20. 重要:确保您的回答与提供的子部分标题和章节位置一致。
</指导说明>

<格式>
- 使用清晰、专业的语言,适合学术或技术受众
- 使用适当的段落、标题和结构组织您的回答
- 文档中的每个事实都必须有方括号[X]形式的IEEE风格引用,其中X是文档元数据中的确切source_id
- 引用应出现在包含所支持信息的句子末尾
- 多个引用应以逗号分隔:[X],[Y],[Z]
- 无需返回参考文献部分。只需在答案中提供引用编号。
- 切勿创建自己的引用编号系统 - 使用文档中的确切source_id值。
- 切勿将引用格式化为可点击链接或markdown链接,如"([1](https://example.com))"。始终仅使用方括号。
- 如果您不确定source_id,切勿编造引用编号。省略引用比猜测更好。
- 切勿在回答中包含或提及引导性问题。它们仅用于帮助指导您的思考。
- 始终专注于直接从文档中的信息回答用户的查询。
</格式>

<不正确的引用格式>
请勿使用以下任何不正确的引用格式:
- 使用括号和markdown链接:([1](https://github.com/MODSetter/SurfSense))
- 在方括号周围使用括号:([1])
- 使用超链接文本:[链接到来源1](https://example.com)
- 使用脚注样式:...礁系统¹
- 在不知道source_id时编造引用编号

仅使用简单方括号[1]或多个引用[1],[2],[3]
</不正确的引用格式>

请注意,引用编号与source_id值(1、13和21)完全匹配,并未按顺序重新编号。引用遵循IEEE样式,使用方括号并出现在句子末尾。

<用户查询指导>
当您看到类似以下的用户查询:
    <user_query>
        提供所有线性问题。
    </user_query>

专注于使用提供的文档中的信息回答此查询。

如果在<guiding_questions>部分提供了引导性问题,请仅将它们用于指导您的思考过程。不要在您的回答中提及或列出这些问题。

确保您的回答:
1. 直接回答用户的查询
2. 符合提供的子部分标题和章节位置
3. 为文档中的所有信息使用适当的引用
4. 结构良好且语气专业
</用户查询指导>

小结

今天深入研究了 SurfSense 文档问答流程中的几个关键节点,包括:问题改写生成大纲搜索文档重排序 以及最终的 报告撰写,这些步骤涉及大量 Prompt 的编写,我们平时在写 Prompt 时可以参考这里的设计技巧。

另外,我们还学习了不少搜索技术,包括 PostgreSQL 的全文检索,基于 pgvector 扩展实现的向量检索,以及通过 rerankers 实现重排序。今天的内容包含不少代码,感兴趣的朋友建议对照着源码学习会更高效。

好了,关于 SurfSense 的问答流程基本上都讲完了,还差最后一个点,那就是 NotebookLM 的核心功能 —— 生成播客,我们明天继续。


学习 SurfSense 的文档问答流程

昨天,我们学习了在 SurfSense 中添加文档之后的入库流程,包括总结、分块、向量化等,现在一切准备就绪,到了对文档进行问答的时候了。

文档问答页面

点击左侧菜单中的 "Researcher" 进入问答页面:

surfsense-researcher.png

输入框的最左边是选择数据源,在 SurfSense 中被称为 连接器(Connectors),可以是我们上传的文件、添加的网页、添加的 Youtube 视频、或者通过浏览器插件抓取的内容:

surfsense-select-connectors.png

SurfSense 还支持添加其他的连接器,比如搜索引擎 Tavily、聊天工具 Slack、项目管理工具 Linear、知识库 Notion 等等:

surfsense-connectors.png

关于其他的连接器我们后面再看,暂时先不管。

输入框的中间用于选择 搜索模式(Search Mode),分 Full DocumentDocument Chunks 两种:

  • Full Document - 根据用户问题检索文档表,找出最相关的文档,返回文档全文,这种模式适用于文档多且单个文档比较小的场景;
  • Document Chunks - 根据用户问题检索分块表,找出最相关的分块,返回一个个的文档片段,这种模式适用于文档较大的场景;

输入框的最右边是选择 研究模式(Research Mode),分 GeneralDeepDeeper 三种,这三种模式会影响检索的数据量和答案的丰富度:

  • General - 检索 10 条数据,答案包括 1 个章节;
  • Deep - 检索 20 条数据,答案包括 3 个章节;
  • Deeper - 检索 30 条数据,答案包括 6 个章节;

体验文档问答

我们以之前的 MRAG 文档作为示例,体验下 SurfSense 的问答功能。首先,连接器选 File,搜索模式选 Document Chunks,研究模式选 General,然后输入一个简单的问题 What is MRAG?

surfsense-qa.png

下面首先会出现一个终端,实时输出 SurfSense 的处理过程,从图中可以看出 SurfSense 接到用户查询后,开始生成答案大纲,生成了 1 个章节和 5 个研究问题,然后针对这些问题分别进行搜索,搜索的结果紧接着终端显示在下面:

surfsense-qa-sources.png

最后根据搜索结果回答用户:

surfsense-qa-result.png

从结果来看,回答的结构比较清晰,符合预期,且带有引用来源,看上去也很靠谱。不过仔细看还是能发现不少问题,比如:引用的文档片段看不到完整内容,也无法跳转到原文对应的位置,导致溯源功能形同虚设;另外答案也不全面,比如第二节只提到了 MRAG 1.0 和 MRAG 2.0 却丢掉了 MRAG 3.0 的内容;第三节甚至出现了 Mixed Language Models (MLLMs) 这样的幻觉错误,连 MLLM 的全称都搞错了。

文档问答的实现原理

尽管存在很多问题,但并不妨碍我们去看它的实现原理。考虑到这是一个很新的项目,在社区也很活跃,还在不断地演进,相信后面会越来越完善。

好了,我们继续看代码。问答接口为 POST /chat,位于 surfsense_backend/app/routes/chats_routes.py 文件中:

surfsense-chat-router.png

核心逻辑是下面的 stream_connector_search_results() 函数,这是一个流式处理搜索结果的函数,用于将搜索结果和回答内容实时传输给客户端,点进去看它的实现:

async for chunk in researcher_graph.astream(
    initial_state, 
    config=config, 
    stream_mode="custom",
):
    if isinstance(chunk, dict) and 'yeild_value' in chunk:
        yield chunk['yeild_value']

yield streaming_service.format_completion()

很显然,这是一个 生成器函数,通过 researcher_graph.astream() 不断产生内容,并通过 yield 关键字返回,最终通过 FastAPI 的 StreamingResponse 实现流式输出。

Python 中的生成器函数

Python 中的 生成器函数(Generators) 是一种特殊的函数,它使用 yield 关键字来返回值,而不是普通的 return 返回。当函数中包含 yield 语句时,这个函数就变成了生成器函数,调用生成器函数不会立即执行函数体,而是返回一个生成器对象,可以通过 next()for 循环从生成器函数取值。

def simple_generator():
    yield 1
    yield 2
    yield 3

for value in simple_generator():
    print(value)

生成器函数的核心特点是惰性求值和状态保持。当我们从生成器函数取值时,它不会一次性返回所有结果,而是每次返回一个值,函数会记住当前执行的位置,下次调用时从该位置继续执行。

在上面这段代码中,通过生成器函数,可以将消息实时流式传输给客户端,而不是等待所有结果都准备好才一次性返回,这样可以提供更好的用户体验。

Researcher Graph

接着再来看 researcher_graph 的实现:

def build_graph():
    workflow = StateGraph(State, config_schema=Configuration)
    
    workflow.add_node("reformulate_user_query", reformulate_user_query)
    workflow.add_node("write_answer_outline", write_answer_outline)
    workflow.add_node("process_sections", process_sections)

    workflow.add_edge("__start__", "reformulate_user_query")
    workflow.add_edge("reformulate_user_query", "write_answer_outline")
    workflow.add_edge("write_answer_outline", "process_sections")
    workflow.add_edge("process_sections", "__end__")

    graph = workflow.compile()
    graph.name = "Surfsense Researcher"
    
    return graph

graph = build_graph()

这里通过 LangGraph 构建了一个名为 Surfsense Researcher 的智能体流程,这个智能体比较简单,是一个线性工作流程,如下:

langgraph-workflow.png

整个工作流包含三个主要节点:

  • reformulate_user_query:重新表述用户查询,也就是对用户的问题进行改写;
  • write_answer_outline:根据不同的研究模式生成大纲,并为每个大纲生成多个搜索问题;
  • process_sections:针对大纲中的每个章节,调用连接器去搜索,并生成对应章节的内容;

LangGraph 框架

LangGraph 是 LangChain 开源的一个多智能体框架:

langgraph.png

LangGraph 允许用户自定义包含循环的流程,并使用 状态图(State Graph) 来表示智能体的调用过程,提供了对应用程序的流程和状态更精细的控制。它的关键特性如下:

  • 循环和分支(Cycles and Branching):支持在应用程序中实现循环和条件语句;
  • 持久性(Persistence):自动保存每一步的执行状态,支持在任意点暂停和恢复,以实现错误恢复、人机协同、时间旅行等功能;
  • 人机协同(Human-in-the-Loop):支持在行动执行前中断执行,允许人工介入批准或编辑;
  • 流支持(Streaming Support):图中的每个节点都支持实时地流式输出;
  • 与 LangChain 的集成(Integration with LangChain):LangGraph 与 LangChain 和 LangSmith 无缝集成,但并不强依赖于它们。

关于 LangGraph 的使用,我在去年的时候写过一篇博客,感兴趣的朋友可以看看:

小结

从 LangGraph 的工作流可以看出,这是一个典型的研究报告生成智能体,之前在调研 Deep Search 和 Deep Research 的时候,有大量的开源项目使用了类似的处理流程,尽管如此,SurfSense 的处理流程中还有一些细节值得学习,我们明天继续。


学习 SurfSense 的数据入库流程

花了两天时间,我们学习了 SurfSense 文档管理功能,学习了上传文件、添加网页、添加 Youtube 视频、浏览器插件四种添加文档的方式,以及每种添加方式的实现原理。不过目前还只是数据摄入部分,我们今天继续研究下后面的流程,看看这些文档是如何入库的。

数据入库流程

我们总结下几种添加文档的方式,可以看出,最终的结果都是转为纯文本或 Markdown 格式:

surfsense-document-parse.png

拿到文本之后的入库流程几乎是一样的,如下:

surfsense-add-document-code.png

主要分三个步骤:

  1. 对整个文档进行总结和向量化;
  2. 将文档分块,对每个分块进行向量化;
  3. 将文档和分块数据入库;

文档总结

SurfSense 使用长文本大模型对完整文档做总结,从配置文件可以知道,默认的模型是 Google 的 Gemini 2.0 Flash,上下文窗口达到 100 万 token,可以塞进去一部《红楼梦》:

LONG_CONTEXT_LLM="gemini/gemini-2.0-flash"

文档总结的代码如下:

summary_chain = SUMMARY_PROMPT_TEMPLATE | config.long_context_llm_instance
summary_result = await summary_chain.ainvoke({"document": file_in_markdown})
summary_content = summary_result.content

这里通过 LangChain 的 LCEL(LangChain Expression Language)语法 创建一个 摘要生成链(summary chain),将提示词模版和大模型调用链接在一起,其中 SUMMARY_PROMPT_TEMPLATE 的定义如下:

SUMMARY_PROMPT_TEMPLATE = PromptTemplate(
    input_variables=["document"],
    template=SUMMARY_PROMPT
)

后面的 long_context_llm_instance 定义如下:

LONG_CONTEXT_LLM = os.getenv("LONG_CONTEXT_LLM")
long_context_llm_instance = ChatLiteLLM(model=LONG_CONTEXT_LLM)

这里有一个挺长的文档总结 Prompt 可以参考,见 surfsense_backend/app/prompts/__init__.py 文件。

LCEL 语法

上面的 | 连接符有点类似 Linux 命令行中的管道,是 LangChain 的一种特色写法,表示数据会从左侧的提示模板流向右侧的大模型调用。要注意的是,这并不是 Python 的原生特性,而是 LangChain 的语法糖。在 Python 中 | 运算符通常用于:

  • 位运算(bitwise OR)
  • 集合的并集操作
  • 类型注解中的联合类型(Union types)

而 LangChain 是通过 重载(overload) 这个运算符,使其具有了新的含义,这种设计灵感来自于 Linux 中的管道操作符,通过这种管道语法使得代码更加简洁和可读,让数据处理的流程更加清晰。

在 Python 中重载运算符是通过实现特殊方法(也称为魔术方法或双下划线方法)来实现的,比如下面这些:

class MyClass:
    def __add__(self, other):      # +
        pass
    
    def __sub__(self, other):      # -
        pass
    
    def __mul__(self, other):      # *
        pass
    
    def __truediv__(self, other):  # /
        pass
    
    def __eq__(self, other):       # ==
        pass
    
    def __lt__(self, other):       # <
        pass
    
    def __gt__(self, other):       # >
        pass
    
    def __or__(self, other):       # |
        pass
    
    def __and__(self, other):      # &
        pass

可以看到,要重载 | 运算符,需要实现 __or__ 方法。对于 LangChain 来说,它的几个核心类,比如 PromptTemplateBaseChatModel,都统一继承自 Runnable 接口,我们打开 Runnable 源码,就可以发现 LangChain 中 | 连接符的奥秘所在:

langchain-runnable.png

向量化

接着我们计算整个文档的向量:

summary_embedding = config.embedding_model_instance.embed(summary_content)

其中 embedding_model_instance 定义如下:

EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL")
embedding_model_instance = AutoEmbeddings.get_embeddings(EMBEDDING_MODEL)

这里通过 Chonkie 库的 AutoEmbeddings 来加载 Embedding 模型,它会根据模型名称自动选择使用:

  • OpenAIEmbeddings
  • Model2VecEmbeddings
  • CohereEmbeddings
  • JinaEmbeddings
  • SentenceTransformerEmbeddings

SurfSense 使用默认模型是 Mixedbread 开源的 mxbai-embed-large-v1

EMBEDDING_MODEL="mixedbread-ai/mxbai-embed-large-v1"

这个模型不大,但效果很好,在 24 年的时候,曾经一度在 MTEB 榜单达到 SOTA 水平。这个模型可以通过 sentence_transformers 库直接加载调用,很适合本地使用。

文档分块

由于长文档不适合检索和召回,在问答时很容易超出大模型的上下文限制。所以我们往往将长文档拆分成一个个的片段,问答时只召回适当的片段。下面是文档分块的逻辑代码:

chunks = [
    Chunk(
        content=chunk.text,
        embedding=config.embedding_model_instance.embed(chunk.text),
    )
    for chunk in config.chunker_instance.chunk(file_in_markdown)
]

其中 chunker_instance 的定义如下,使用了 Chonkie 库的 RecursiveChunker 来分块:

chunker_instance = RecursiveChunker(
    chunk_size=getattr(embedding_model_instance, 'max_seq_length', 512)
)

很明显,这里使用的是 Recursive Chunking(递归分块) 技术。递归分块通过使用一组分隔符以层级和迭代的方式将输入文本划分为更小的块,这种方法允许文档按照不同的层级进行分割,从而更好地保留文本的原始结构,特别适用于具有多个层级结构的文档。比如我们可以先尝试根据双换行符 \n\n 分割,这样分割出来的是章节;如果超出了分块大小限制,再按单个换行符 \n 分割,得到的是段落;最后再按空格或句号等其他字符分割。

除了 RecursiveChunker,Chonkie 库还支持一些其他分块技术:

chonkie-chunker.png

感兴趣的朋友可以去看它的官网文档:

数据入库

最后,将文档和分块保存到数据库:

document = Document(
    search_space_id=search_space_id,
    title=file_name,
    document_type=DocumentType.FILE,
    document_metadata={
        "FILE_NAME": file_name,
        "SAVED_AT": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
    },
    content=summary_content,
    embedding=summary_embedding,
    chunks=chunks,
)

session.add(document)
await session.commit()
await session.refresh(document)

这里有两点比较有意思,值得注意。

第一点是 DocumentChunk 实际上是两张表,通过 SQLAlchemy ORM 的 relationship 关联在一起:

class Document(BaseModel, TimestampMixin):
    __tablename__ = "documents"
    
    title = Column(String, nullable=False, index=True)
    document_type = Column(SQLAlchemyEnum(DocumentType), nullable=False)
    document_metadata = Column(JSON, nullable=True)
    
    content = Column(Text, nullable=False)
    embedding = Column(Vector(config.embedding_model_instance.dimension))
    
    search_space_id = Column(Integer, ForeignKey("searchspaces.id", ondelete='CASCADE'), nullable=False)
    search_space = relationship("SearchSpace", back_populates="documents")
    chunks = relationship("Chunk", back_populates="document", cascade="all, delete-orphan")

class Chunk(BaseModel, TimestampMixin):
    __tablename__ = "chunks"
    
    content = Column(Text, nullable=False)
    embedding = Column(Vector(config.embedding_model_instance.dimension))
    
    document_id = Column(Integer, ForeignKey("documents.id", ondelete='CASCADE'), nullable=False)
    document = relationship("Document", back_populates="chunks")

可以了解下 SQLAlchemy ORM 这里定义表、字段以及关联表的方式,可以看下这里的快速入门文档:

另一点是,这里的 embedding 字段是 Vector 类型,也就是向量数据类型,但是 SurfSense 使用的 PostgreSQL 数据库并不是向量数据库。这背后其实是通过 pgvector 这个扩展实现的,pgvector 为 PostgreSQL 增加了向量数据类型以及高效的向量索引方法,使得 PostgreSQL 不仅能够存储向量数据,而且也可以通过向量进行相似性检索。

我们之前学习 SupaBase 的时候也提过,它的 GraphQL API 功能是通过 pg_graphql 扩展实现的,不得不感叹,PostgreSQL 数据库的扩展性是真强。


学习 SurfSense 的浏览器扩展

昨天体验了 SurfSense 的文档管理功能,包括上传文件、添加网页、添加 Youtube 视频这三种添加文档的方式。其中,添加网页是通过 FirecrawlPlaywright 实现的,这两种方式其实都有弊端,比如不能访问内部网页,不能绕过身份认证,等。于是,SurfSense 还提供了另一种添加文档的方式 —— 浏览器扩展,通过浏览器扩展,我们可以添加任何网页,包括那些受到身份验证保护的网页。

构建 SurfSense 浏览器扩展

首先进入扩展目录,复制 .env.example 文件配置环境变量:

$ cd surfsense_browser_extension
$ cp .env.example .env

环境变量比较简单,保持默认值即可:

PLASMO_PUBLIC_API_SECRET_KEY = "surfsense"
PLASMO_PUBLIC_BACKEND_URL = "http://127.0.0.1:8000"

然后通过 pnpm install 安装依赖:

$ pnpm install

注意安装后可能会有下面这样的警告信息:

╭ Warning ───────────────────────────────────────────────────────────────────────────────────╮
│                                                                                            │
│   Ignored build scripts: @swc/core, esbuild, lmdb, msgpackr-extract, sharp.                │
│   Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.   │
│                                                                                            │
╰────────────────────────────────────────────────────────────────────────────────────────────╯

它的意思是,在安装期间有一些依赖项需要运行脚本,这个默认是不允许的,我们需要手动执行 pnpm approve-builds 命令来允许。

$ pnpm approve-builds

命令会弹出提示,选择要运行哪些脚本,按 <a> 全选即可:

pnpm-approve-builds.png

最后构建浏览器扩展:

$ pnpm build

这个命令默认构建的是 Chrome 扩展,也支持构建其他浏览器的扩展:

$ pnpm build --target=firefox
$ pnpm build --target=edge

构建好的 Chrome 扩展位于 ./build/chrome-mv3-prod 目录。

Plasmo 框架

SurfSense 的浏览器扩展是使用 Plasmo 框架开发的:

plasmo.png

这是一款功能强大的浏览器扩展 SDK,功能特性如下:

plasmo-features.png

Plasmo 将各大浏览器扩展抽象成统一标准,使用 Plasmo 构建浏览器扩展,可以不用操心扩展的配置文件,以及不同浏览器构建时的一些奇怪特性等。

安装 SurfSense 浏览器扩展

接下来,打开 Chrome 浏览器的扩展程序页面 —— chrome://extensions/

chrome-extensions.png

开启 “开发者模式”,点击 “加载已解压的扩展程序”,然后选择构建好的 Chrome 扩展目录(./build/chrome-mv3-prod),扩展即安装完成。在浏览器的扩展栏可以看到 SurfSense 的小图标,点击小图标弹出扩展的配置页面:

surfsense-extension.png

在这里需要填写 SurfSense 的 API KEY,这个可以在平台左下角点击账号进行查看:

surfsense-api-key.png

填上 API KEY 之后进入扩展的使用页面:

surfsense-extension-home.png

使用 SurfSense 浏览器扩展

进入 SurfSense 扩展的使用页面后,首先需要选择将网页推送到哪个搜索空间,下面可以看到三个按钮:

  • Clear Inactive History - 清空不活跃的网页缓存,指的是那些已经关闭的网页标签
  • Save Current Page - 将当前网页内容保存到缓存中
  • Save to SurfSense - 将缓存的网页内容推送到 SurfSense 的搜索空间中

我们拿 SurfSense 的 Github 页面做个示例:

首先点击 Save Current Page 保存网页到缓存,然后点击 Save to SurfSense 将网页推送到 SurfSense 的搜索空间中,刷新文档列表页面如下:

surfsense-extension-list.png

代码实现

SurfSense 扩展的主要逻辑位于 routes/pages/HomePage.tsx 文件:

surfsense-extension-homepage-code.png

三个按钮对应的函数分别是:

  • clearMem
  • saveCurrSnapShot
  • saveDatamessage

其中 saveCurrSnapShot 的逻辑如下:

surfsense-extension-save-snapshot-code.png

首先通过 chrome.scripting.executeScript() 获取网页的完整 HTML 源码,然后使用 dom-to-semantic-markdown 库的 convertHtmlToMarkdown() 函数将 HTML 转换为 Markdown 文本,保存到浏览器的缓存中,这个缓存可以在浏览器的 Extension storage 中看到:

surfsense-extension-storage.png

saveDatamessage 的逻辑如下:

surfsense-extension-save-data-code.png

通过 Plasmo 的 sendToBackground() 发送一个 savedata 消息,这个消息由后台脚本 background/messages/savedata.ts 接受并处理,向 SurfSense 发送请求,将 Markdown 保存到指定的搜索空间:

surfsense-extension-save-data-code-2.png

后端逻辑和添加网页、添加 Youtube 视频的逻辑一样,同样位于 documents_routes.py 文件中,接口名为 POST /documents/,由于接受的直接是 Markdown 文本,不需要额外处理,直接 分片 -> 生成向量 -> 入库 一套流程走完即可。

小结

好了,关于 SurfSense 文档管理基本上就这些内容,花了两天时间,介绍了上传文件、添加网页、添加 Youtube 视频、浏览器插件四种添加文档的方式,并简单研究了每种添加方式的实现。不过目前还只是数据摄入部分,后面的这套数据入库流程,以及检索、问答、生成播客、等,才是 SurfSense 的核心功能,我们明天继续。


学习 SurfSense 的文档管理

昨天给大家介绍了一款号称是 NotebookLM 的开源平替的项目 —— SurfSense,经过一些前期准备工作,我们在本地已经成功部署了它,并通过 Google OAuth 成功登录:

surfsense-home.jpg

接下来,我们再来看看它的各项功能特性,顺便研究下每个功能是如何实现的。首先当然是它的文档管理功能。

一个小插曲

昨天注册 Unstructured.io 之后,会进入一个 Contact Sales 页面,无法进入 Platform 页面,也拿不到 API KEY,后来就放弃了。今天抱着试一试的态度,通过 F12 审查这个页面的源码,看看能不能绕过这个限制,突然发现这个页面只是一层全屏弹框:

unstructured-io-contact-sales.png

直接在 "Elements" 里删除这个弹框的 HTML 就能看到控制台页面了:

unstructured-io.png

进而也拿到了 API KEY,新用户有 14 天的免费试用期。

文档管理

接着我们继续看 SurfSense 的文档管理功能。首次进入 SurfSense 的控制台页面时,需要手动创建一个 搜索空间(Search Space)

surfsense-create-search-space.png

点击创建好的空间,进入文档管理页面:

surfsense-documents.png

在左侧菜单中可以看到,我们有三种方式来添加文档:上传文件、添加网页、添加 Youtube 视频。

上传文件

SurfSense 支持数十种不同的文件格式,我们还是拿《A Survey of Multimodal Retrieval-Augmented Generation》这篇 MRAG 的论文 PDF 作为示例:

surfsense-upload-file.png

点击上传后,要稍等一会,在后端可以看到不断地在刷日志,处理结束后刷新文档列表,如下(可以看到 SurfSense 为文档生成了一份摘要):

surfsense-documents-list.png

上传文件的实现

上传文件的代码逻辑位于 documents_routes.py 文件中,接口名为 POST /documents/fileupload

surfsense-upload-file-code.png

通过 FastAPI 的 BackgroundTasks 创建一个后台任务对文件进行处理。根据不同的文件后缀分别处理:

  • .md, .markdown

对于 Markdown 格式的文本,直接读取文件内容。

  • .mp3, .mp4, .mpeg, .mpga, .m4a, .wav, .webm

对于音频文件,通过 LiteLLM 的 atranscription() 方法,调用 STT(Speech-to-Text,语音转文本)服务,在 .env 文件中可以看到默认的 STT 是 OpenAI 的 whisper-1 服务。

STT_SERVICE="openai/whisper-1"
  • 其他类型

对于其他类型的文件,统一使用 LangChain 的 UnstructuredLoader 进行处理,实际上就是调 Unstructured.io 的接口:

loader = UnstructuredLoader(
    file_path,
    mode="elements",
    post_processors=[],
    languages=["eng"],
    include_orig_elements=False,
    include_metadata=False,
    strategy="auto",
)

docs = await loader.aload()

LangChain 支持大量的文档处理器,被称为 Document loaders,如果 Unstructured.io 的接口不可用,也可以考虑换成其他的:

添加网页

SurfSense 支持添加网页文档,输入 URL 自动抓取网页的内容:

surfsense-add-webpage.png

比如这里我对 Google 的 A2A 比较感兴趣,就将 A2A 的这篇博客丢进去:

同样的,等后台处理一会,刷新文档列表,如下:

surfsense-webpage-list.png

添加网页的实现

添加网页的代码逻辑同样位于 documents_routes.py 文件中,接口名为 POST /documents/

surfsense-add-webpage-code.png

通过 FastAPI 的 BackgroundTasks 创建一个后台任务对网页进行处理:

if config.FIRECRAWL_API_KEY:
    crawl_loader = FireCrawlLoader(
        url=url,
        api_key=config.FIRECRAWL_API_KEY,
        mode="scrape",
        params={
            "formats": ["markdown"],
            "excludeTags": ["a"],
        },
    )
else:
    crawl_loader = AsyncChromiumLoader(urls=[url], headless=True)

url_crawled = await crawl_loader.aload()

可以看到,这里同样是用的 LangChain 的 Document loaders 来处理网页,如果配置了 FIRECRAWL_API_KEY,就使用 FireCrawlLoader 调用 FireCrawl 的接口 ,否则就使用 AsyncChromiumLoader 调用 Chrome 浏览器,它的底层实际上是靠 Playwright 自动操作浏览器实现的。

添加 Youtube 视频

最后,我们来看下 SurfSense 的添加 Youtube 视频的功能:

surfsense-add-youtube.png

这里随便找了一篇介绍 MCP 的视频:

将视频地址填入后,下面还可以对视频进行预览。提交之后,稍等片刻,刷新文档列表,如下:

surfsense-youtube-list.png

添加 Youtube 视频的实现

添加 Youtube 视频的代码逻辑同样位于 documents_routes.py 文件中,接口名为 POST /documents/

surfsense-add-youtube-code.png

后台任务的处理逻辑如下:

surfsense-add-youtube-code2.png

首先通过 https://www.youtube.com/oembed 接口获取视频的基本信息,包括视频标题、作者、缩略图等:

surfsense-youtube-oembed.png

注意这里使用了 Python 中的异步请求库 aiohttp 来发送 HTTP 请求,这个库默认不走系统代理,需要稍微改下代码才能访问 Youtube 的接口:

async with aiohttp.ClientSession() as http_session:
    async with http_session.get(
        oembed_url, 
        params=params, 
        proxy="http://127.0.0.1:7890"
    ) as response:
        video_data = await response.json()

接着程序使用另一个开源库 YouTube Transcript API 抓取视频字幕:

youtube-transcript-api.png

最终存到数据库中的文本信息实际上就是视频的字幕。


SurfSense 介绍:NotebookLM 的开源平替

昨天我给大家介绍了一款 AI 笔记助手 —— NotebookLM,使用它可以方便地对我们的资料进行处理,支持智能问答和引用,一键生成学习指南、脑图、FAQ 等内容格式,以及将文本转换成播客,极大提升了学习的灵活性和便捷性。

今天再给大家介绍一个 Github 上最近比较热门的开源项目 —— SurfSense,它号称是 NotebookLM 的开源平替:

surfsense-logo.png

SurfSense 不仅实现了 NotebookLM 的各项功能,而且高度可定制,同时也更加的开放。比如它支持数十种不同类型的文件格式;支持连接各种外部数据源 Tavily、LinkUp、Slack、Linear、Notion、YouTube、GitHub 等;支持对接 150+ 不同的大模型,6000+ 不同的嵌入模型以及几乎所有主流的重排序模型。

下面我就带大家一起,在本地把 SurfSense 跑起来看看。

安装前准备

根据官方的 Prerequisites 文档,在安装之前,我们需要做一些准备工作:

安装 PGVector

SurfSense 平台使用 PostgreSQL 数据库,同时需要安装 pgvector 扩展支持向量类型,用于文档的向量检索,可以直接通过 Docker 安装:

$ docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres pgvector/pgvector:pg17

注意必须设置一个初始密码,否则启动报错。

开启 Google People API

SurfSense 默认使用 Google OAuth 认证来实现登录,需要开通 Google People API 服务。

首先进入 Google API Library

google-api-library.png

搜索 People API

google-people-api-search.png

点击进去后,点击 "Enable" 开启 Google People API 服务:

google-people-api.png

然后进入 "Credentials" 页面,创建一个凭据(注意凭据类型选 "OAuth client ID"):

google-create-credentials.png

在填写表单时,客户端名称随便填,主要是下面两个参数要填对:

  • Authorised JavaScript origins

    • http://localhost:8000
  • Authorised redirect URIs

    • http://localhost:8000/auth/google/callback

如下图所示:

google-create-credentials-oauth.png

创建成功后,得到 Client ID 和 Client secret 两个配置:

google-client-id-secret.png

获取 Unstructured.io API KEY

SurfSense 使用 Unstructured.io 的 API 进行文档解析,不过当前 Unstructured.io 注册后会进入一个 Contact Sales 页面,无法进入 Platform 页面,也拿不到 API KEY,这一步暂时跳过。

获取 LangSmith API KEY(可选)

这一步是可选的,但是监控 LLM 调用是推荐做法。

LangSmith 是 LangChain 开发的免费开源的 LLM 可观测平台,我们可以自己独立部署,也可以直接免费使用在线服务:

lang-smith.png

获取 Firecrawl API KEY(可选)

SurfSense 通过 Firecrawl 来爬取 URL 内容,我们可以免费注册,注册后有免费的额度:

firecrawl.png

配置大模型

最后,SurfSense 通过 LiteLLM 调用大模型,所以我们必须有可调用的大模型服务,按 LiteLLM 的规范配置好对应的环境变量,比如 OPENAI_API_KEYGEMINI_API_KEY 等,参考 LiteLLM 的文档:

SurfSense 安装

一切准备就绪,接下来就可以安装 SurfSense 了。

尽管官方提供了两种安装方式:Docker 安装 和 手工安装,但是由于项目比较新,变动比较频繁,推荐使用手工安装,过程更可控。

首先,克隆代码:

$ git clone https://github.com/MODSetter/SurfSense.git

SurfSense 包含前后端两个部分,需要分别安装。

安装后端

进入后端目录,复制 .env.example 文件配置环境变量,基本上就是上一节我们得到的 API KEY 等信息:

$ cd surfsense_backend
$ cp .env.example .env

然后通过 uv sync 安装依赖:

$ uv sync

uv 是一个极速的 Python 包和项目管理工具,uv sync 是它的一个命令,用于更新项目的环境,确保项目环境中的依赖与 uv.lock 文件一致。另外,它会检查虚拟环境(.venv)是否存在,如果不存在,将自动创建它。

依赖安装完毕后,通过 uv run 启动服务:

$ uv run main.py

如果一切设置正确,可以看到服务正在 http://localhost:8000 上运行的日志。

安装前端

接下来运行前端,首先进入前端目录,复制 .env.example 文件配置环境变量:

$ cd surfsense_web
$ cp .env.example .env

前端的环境变量比较简单,就只有一个 NEXT_PUBLIC_FASTAPI_BACKEND_URL=http://localhost:8000,保持默认值即可。

然后通过 pnpm install 安装依赖:

$ pnpm install

pnpmnpm 的命令行接口基本一致,但是 pnpm 是一个更现代和高效的包管理工具,在性能和存储效率上更具竞争力。

最后运行前端服务:

$ pnpm run dev

启动成功后,默认监听 3000 端口,在浏览器输入 http://localhost:3000 即可访问 SurfSense 首页:

surfsense-home.jpg

点击 "Sign in with Google" 进入登录页面:

surfsense-login.png

继续点击 "Continue with Google" 进入 Google 认证页面:

surfsense-login-google.png

认证通过后,进入 SurfSense Dashboard 页面:

surfsense-dashboard.png

接下来就可以愉快的体验 SurfSense 的各个功能了。


NotebookLM 介绍:Google 打造的 AI 笔记助手

在当今信息爆炸的时代,我们每天都面临着海量的数据和知识。如何高效地处理、理解和应用这些信息,成为了现代学习者、研究者和专业人士的重要挑战。为了解决这一问题,Google 推出了一款革命性的 AI 工具 —— NotebookLM,这是一款基于最新的 Gemini 大模型构建的研究与笔记助手,旨在为用户提供个性化的 AI 研究助手体验。

notebooklm-home.png

NotebookLM 的核心理念是 Think Smarter, Not Harder(巧思胜苦干),与传统的笔记工具不同,NotebookLM 不仅能够存储信息,还能深入理解内容,建立知识连接,并基于用户提供的可信资料生成有价值的洞察。

核心特性

NotebookLM 具备如下几个核心特性:

  • 多样化资料上传和处理:基于 Gemini 2.0 的多模态理解能力,NotebookLM 能够处理各种类型的资料,包括 PDF 文件、网站、YouTube 视频、音频文件、Google 文档或 Google 幻灯片等;
  • 智能问答与深度洞察:基于上传的资料,NotebookLM 能够回答用户的各种问题,提供深度洞察。它的回答都基于用户提供的资料,并附带精确引用,显示信息来源,确保了回答的可靠性和可验证性;
  • 一键生成多种内容格式:NotebookLM 能够自动生成多种实用的内容格式,包括内容摘要、常见问题解答(FAQ)、时间线、简报文档和脑图等;
  • 随时随地边听边学:全新的 “音频概览” 功能可将文本内容转换为类似播客的音频讨论,便于移动学习,提升学习体验;

下面带大家一起体验下 NotebookLM 的基本功能。

添加数据源

创建一个新笔记,第一步需要添加笔记的数据源:

notebooklm-add-source.png

这里支持多种途径添加数据源:

  • 上传文件:支持上传 PDF、TXT、Markdown 以及音频等格式的文件;
  • Google Drive:支持导入 Google Drive 中的文档和幻灯片;
  • 链接:支持输入网页或 Youtube 视频地址;
  • 粘贴文本:支持手工复制粘贴添加数据源内容;

另外,右上角还有一个 "Discover sources" 的功能,可以输入你感兴趣的话题进行检索:

notebooklm-discover-sources-empty.png

如果你第一次使用这个工具,可能一时想不到什么话题,可以尝试点击下面的 "I'm feeling curious",NotebookLM 会随机搜索一些话题供你参考。下面是我输入 "多模态检索增强生成" 后搜索的结果:

notebooklm-discover-sources-result.png

可以看到搜索的质量还蛮高的,对于手头没有资料又想学习某个主题的用户非常友好。不过我这里暂时不用这些,而是使用最近在看的一篇关于多模态 RAG 的综述论文《A Survey of Multimodal Retrieval-Augmented Generation》:

将这篇论文的 PDF 上传后,NotebookLM 自动生成了笔记的标题和简要概述,如下:

notebooklm-add-source-done.png

知识问答

NotebookLM 的整个面板分成三个部分:

  • Sources
  • Chat
  • Studio

"Sources" 部分我们仍然可以继续添加新的数据源,"Chat" 部分可以对我们上传的数据进行提问:

notebooklm-chat.png

问答的结果支持点击数字角标进行溯源:

notebooklm-chat-cite.png

内容生成

NotebookLM 还支持根据你添加的数据源生成多种实用的内容格式,包括:

  • 脑图(Mind map)

notebooklm-mind-map.png

  • 学习指南(Study guide)

notebooklm-study-guide.png

  • 简报文档(Briefing doc)

notebooklm-briefing-doc.png

  • 常见问题(FAQ)

notebooklm-faq.png

  • 时间线(Timeline)

notebooklm-timeline.png

音频概览

最后是音频概览(Audio Overview)功能,它可以说是 NotebookLM 的杀手锏,也是它广受好评的主要原因。

notebooklm-audio-overview.png

用户只需一键点击,就可以将文本内容转换为类似播客的音频讨论,这一功能特别适合那些喜欢听觉学习或需要在通勤、运动等场景下继续学习的用户。

NotebookLM 默认生成英文播客,但就在最近,Google 宣布已支持中文等超过 50 种不同语言的音频生成,可以在用户配置中修改输出语言选项来生成中文音频。

notebooklm-settings.png

下面是我生成的关于 多模态检索增强生成综述 的播客,感兴趣的朋友可以试听下:

可以听出来,英文的效果非常丝滑,中文的语速有点慢,但总体也是不错的,在通勤或运动时听听还是挺不错的。

此外,对于英文音频,NotebookLM 还有一个额外的 交互模式(Interactive mode),用户可以在收听播客的时候随时打断,加入主持人的讨论,跟听电台一样,体验感爆棚:

notebooklm-audio-overview.png

小结

NotebookLM 是一款基于 Gemini 大模型的 AI 笔记助手,旨在帮助用户高效处理和理解信息。其核心理念是 巧思胜苦干,通过多样化的资料处理、多模态理解能力和深度洞察,为用户提供个性化的研究体验。关键特性包括支持多种数据源上传,智能问答功能提供可靠的回答和引用,一键生成学习指南、脑图、FAQ 等内容格式,以及将文本转换成播客的音频概览功能,极大提升了学习的灵活性和便捷性。总之,NotebookLM 是现代学习者和研究者的理想助手,助力知识的获取与应用。