# Human Prose from Human Evidence: SeekStorm, RRF and C#

<!--category-- AI, LLM, RAG, C#, SeekStorm, JobML, Résumés, Evidence -->

<datetime class="hidden">2026-10-13T23:00</datetime>

lucidRESUME needs to turn one complete career transcript into distinct, credible résumés. Select an applied-AI job: foreground agent architecture, evaluation and tool controls. Select a database job: foreground queries and performance.

An early version found useful passages but assembled generic, nearly identical summaries. It copied experience bullets into the introduction. Fluent local-model edits could strengthen the facts: “added authorization controls” became “allowed the agent to act safely”.

That is the problem this part tackles: **choose the right human accounts for the job, assemble them into readable prose, and keep the facts and their source links intact.**

The material already exists across CVs, role notes, READMEs and articles. One source has the architecture; another has the outcome and its qualification. The writer needs a useful selection of those accounts.

This is **part four of lucidRESUME**, following [the evidence ledger](/blog/the-problem-with-resumes), [job-form answers](/blog/lucidresume-evidence-filler) and [the résumé compiler](/blog/lucidresume-web-compiler). Here SeekStorm finds candidate evidence, C# controls its scope, and a local LLM recomposes the prose.

It shares the central idea from [DoomSummarizer](/blog/doomsummarizer-deep-research): do useful reduction before asking the model to write.

[TOC]

## Meet SeekStorm and the C# Package

[SeekStorm](https://github.com/SeekStorm/SeekStorm) is a Rust search engine combining full-text retrieval, vector search and structured facets. Its lexical side supports BM25F; hybrid mode combines the lexical and vector rankings with reciprocal rank fusion. For lucidRESUME, it runs inside the application process, with an index on disk.

My [**SeekStorm.Bindings** NuGet package](https://www.nuget.org/packages/SeekStorm.Bindings) makes that engine usable from C#. It supplies a .NET 10 managed API and native binaries for Windows, Linux and macOS, on x64 and ARM64, with NativeAOT-compatible bindings. Consuming the package does not require building Rust yourself. The examples here use **SeekStorm.Bindings 0.2.4**, wrapping the unmodified **SeekStorm 3.4.1** engine.

[![SeekStorm.Bindings on NuGet](https://img.shields.io/nuget/v/SeekStorm.Bindings?label=SeekStorm.Bindings)](https://www.nuget.org/packages/SeekStorm.Bindings)

That suits a desktop résumé tool: lexical and vector retrieval share a local index, and the career record need not travel to a search service. SeekStorm handles the search machinery. The C# application still decides which claims are accepted, which role owns an account, and what may be passed to the writer.

## Start with Something Worth Retrieving

Here is a small **synthetic** career record:

| ID | Role | Reviewed prose |
|---|---|---|
| A | Applied AI Engineer | Built a C# support-agent runtime using Azure OpenAI and Qdrant retrieval for internal operations. |
| B | Applied AI Engineer | Added per-tool authorization and idempotency keys before allowing the internal agent to update ticket state. |
| C | Senior Software Engineer | Optimised PostgreSQL queries: measured test latency fell from 240 ms to 180 ms. |
| D | Research Developer | Built a Python retrieval prototype; it was never deployed to production. |

For an applied-AI job, A and B are useful together. For a database job, C deserves the space. D may be relevant to retrieval, but cannot establish production-agent experience.

That last distinction survives only if the chunk retains its qualification. Splitting D into “Built a Python retrieval prototype” and a detached deployment caveat makes the search result much easier to misuse.

I covered the broader chunking problem in [RAG Architecture](/blog/rag-architecture) and the reusable analysis pipeline in [DocSummarizer: Building RAG Pipelines](/blog/docsummarizer-rag-pipeline). Here the useful unit is an account of work: its prose, role identity, accepted claim IDs, source references, named skills and optional embedding.

The complete career transcript is authoritative. Imported CVs are its source archive. Generation searches the composed, deduplicated record rather than rescanning every imported document.

SQLite keeps the record. SeekStorm keeps a rebuildable search index. Changing one account updates its indexed payload; it does not require another tour through every old PDF.

## Turn the Vacancy into Search Questions

A job description is several questions, not one large search string. lucidRESUME builds requirement queries from its skills and responsibilities:

| Target | Example question | Evidence to inspect |
|---|---|---|
| Applied AI lead | Agent runtimes and retrieval | A |
| Applied AI lead | Authorization before state changes | B |
| Applied AI lead | Engineering-team leadership | A gap in this example |
| Database engineer | PostgreSQL query performance | C |

The authorization query can retrieve B through exact words. It could also find a differently worded account about preventing duplicate actions and checking permissions before changing tickets. That is where an embedding helps.

Conversely, `PostgreSQL`, `C#` and `Qdrant` are names I want to preserve. Embeddings rank accounts of work; skill claims still need evidence.

[Hybrid search](/blog/rag-hybrid-search-and-indexing) lets the lexical and vector retrievers contribute different candidates. The [bindings source](https://github.com/scottgal/seekstorm-csharp) shows how those operations cross the native boundary.

A pared-down schema looks like this:

```csharp
using SeekStorm.Bindings;
using SeekStorm.Bindings.Models;

using var index = new SeekStormClient();
index.CreateIndex(indexPath, new IndexMeta
{
    Name = "career-prose",
    Inference = SearchConfiguration.ExternalVectors(384)
}, [
    new() { Field = "key", FieldType = "String16", Store = true, Facet = true },
    new() { Field = "role", FieldType = "String16", Store = true, Facet = true },
    new() { Field = "terms", FieldType = "Text", IndexLexical = true },
    new() { Field = "vector", FieldType = "Json", IndexVector = true }
]);
```

`key` gets us back to the canonical prose and its evidence. `role` keeps the account attached to the job where it happened. `terms` contains searchable text and supported skill names; the adapter normalises awkward technical tokens such as `C#` to `csharp`. The original wording remains intact in SQLite.

The 384 dimensions are illustrative. Document and query vectors must share an embedding space; equal array lengths do not make two models compatible. lucidRESUME checks space identity and dimensions, falling back to lexical retrieval when necessary.

## RRF: Let Each Search Produce Its Own Ranking

> **RRF refresher:** Rank fusion combines positions from separate searches, not their raw scores. [DocSummarizer Part 3: Hybrid Search with RRF](/blog/docsummarizer-advanced-concepts#hybrid-search-with-rrf) has the fuller explanation and a C# implementation.

BM25 scores and vector similarities use different scales. [Reciprocal Rank Fusion](https://cormack.uwaterloo.ca/cormacksigir09-rrf.pdf) avoids that mismatch by combining positions:

```text
RRF(document) = sum over result lists of 1 / (k + rank)
```

An absent document contributes nothing from that list. A document appearing in both lists collects both contributions. Stable document identity is what joins those entries; RRF does not recognise that two differently identified paragraphs say the same thing.

DoomSummarizer uses this idea to [combine several signals](/blog/doomsummarizer-deep-research#fusing-heterogeneous-signals). Here SeekStorm combines lexical and vector results.

**The constant matters.** The familiar example uses `k = 60` with one-based ranks. [SeekStorm 3.4.1](https://github.com/SeekStorm/SeekStorm/blob/edf6f77e554c472b3729a547b8e08fb84a590a76/seekstorm/src/search.rs#L1969-L2039) uses `0.6 + position`, starting at zero: a first-place contribution of `1 / 0.6`, not `1 / 61`.

For an **illustrative**, invented pair of result lists:

| Account | Lexical position | Vector position | Native fusion score |
|---|---:|---:|---:|
| A: runtime and retrieval | 1 | 0 | `1/1.6 + 1/0.6 = 2.2917` |
| B: authorization and idempotency | 0 | 2 | `1/0.6 + 1/2.6 = 2.0513` |
| D: retrieval prototype | absent | 1 | `1/1.6 = 0.6250` |

A wins through agreement. B remains strong because it is the lexical first choice. D remains a candidate despite lacking the exact requirement's words.

The smaller offset gives early positions much more influence than the conventional `60` example. Agreement helps, but does not guarantee victory: a document first in one list scores `1.6667`; one third in both scores only `0.7692`.

None of these numbers is a probability that the person can do the job.

## The C# Call Returns Candidates

Given a populated index and an embedding of the requirement:

```csharp
using System.Text.Json.Nodes;

var hits = index.Search(new SearchRequest
{
    Query = "\"authorization\" \"idempotency\" \"state\"",
    QueryType = "Union",
    SearchMode = "Hybrid",
    QueryVector = requirementEmbedding,
    FacetFilter = new JsonArray((JsonNode)
        SearchConfiguration.StringFacet("role", "applied-ai-role")),
    Fields = ["key", "role"],
    Length = 12
});
```

SeekStorm performs the lexical/vector RRF. lucidRESUME does not fuse those same lists again in C#.

The application checks accepted evidence and role scope, downweights explicitly non-production accounts for production requirements, and favours coverage over repeated versions of the same point. Similarity can make D worth inspecting; it cannot turn D into a production deployment.

> **Version detail:** Bindings 0.2.4 enforce vector-query facets before pagination through a compatibility path. SeekStorm 3.4.1 does not perform engine-side vector prefiltering.

The application rechecks the returned role too. [The adapter](https://github.com/scottgal/lucidRESUME/blob/dad8793/src/lucidRESUME.Core/Persistence/SeekStormProseSearch.cs) and [candidate selection](https://github.com/scottgal/lucidRESUME/blob/dad8793/src/lucidRESUME.AI/CareerProseRetriever.cs) show the boundary. Normal generation searches once per requirement across the career corpus, then partitions by role; it refines individual roles when the candidate limit is saturated.

## A Small Evidence Packet Is a Better Writing Prompt

Suppose the job asks for agent architecture and consequential tool actions. A useful packet contains A and B, their claim/source IDs, the section purpose and a word budget. It excludes the rest of the biography.

Conceptually:

```text
Purpose: explain relevant agent-engineering experience
Facts: A, B
Preserve: C#, Azure OpenAI, Qdrant; authorization before state changes
Do not infer: production scale, measured safety, overall platform ownership
Output: prose tied to the supplied claim and evidence IDs
```

This is the [Reduced RAG](/blog/reduced-rag) boundary in practice. The job description controls emphasis. The selected human accounts supply the facts.

A concise introduction can combine the themes:

> Experience building a C# support-agent runtime and adding per-tool authorization.

The role section keeps the concrete tool choices, idempotency keys and ticket-state detail. Repeating the entire runtime bullet above it, with “Software engineer who…” attached, adds almost nothing.

Likewise, C can support database-performance prose. It cannot support:

> Reduced production latency by 25%.

The source says **measured test latency**, with explicit values and direction. A derived percentage does not justify silently changing the environment or causal claim.

Another tempting edit is:

> Enabled the agent to update state safely.

B establishes controls added before state changes. It does not establish an overall safety outcome. The writer can describe the controls without awarding itself a safety certificate.

The local editing pipeline checks numeric facts, preserved qualifiers, assurance wording and claim/evidence IDs. Rejected edits leave the prior wording intact. These checks are conservative heuristics, not a proof that arbitrary generated prose is true.

## Same Career, Different Question

Our paired synthetic test produced an applied-AI introduction combining runtime and authorization work. Its database introduction selected reporting and query optimisation. The roles underneath preserved an unknown production effect for offline evaluation and the measured test-latency result.

The [recorded run and review](https://github.com/scottgal/lucidRESUME/tree/dad8793/docs/research/results/summary-recomposition-2026-10-13) used local `qwen3.5:4b` with **lexical retrieval**. They validate those projections and evidence links; the hybrid rankings above are illustrative.

There is still prose to improve. A small model sometimes proposes a fluent sentence which loses a qualification or borrows a responsibility from the job description. Retaining an accurate sentence is a useful result, even when the attempted rewrite sounded rather good.

The practical value of SeekStorm here is giving that writer a small, relevant, inspectable set of human accounts. Changing the job description changes which accounts receive space. The person, the work they actually did and the sources behind it stay the same.
